Files
fusion/plugins/fusion-plugin-compound-engineering/src/dashboard/CompoundEngineeringView.tsx
gsxdsm 2efa833357 FN-6250: add Compound Engineering session cancellation
Add cancel controls and backend support for preserving interrupted Compound Engineering sessions.

- Add an orchestrator cancel path and POST route that stops live work without deleting session history.
- Wire dashboard APIs, hooks, and UI buttons to cancel active, launching, or awaiting-input sessions.
- Cover cancellation behavior across orchestrator, routes, hooks, flow controls, and session panel tests.
- Document the cancel versus discard workflow in plugin docs and README.

Files changed:
 docs/plugins/compound-engineering.md               |  13 ++-
 .../fusion-plugin-compound-engineering/README.md   |  11 ++-
 .../src/__tests__/orchestrator-cancel.test.ts      | 101 +++++++++++++++++++++
 .../src/__tests__/session-routes.test.ts           |  35 +++++++
 .../src/dashboard/CeFlow.tsx                       |  16 +++-
 .../src/dashboard/CompoundEngineeringView.css      |  20 ++++
 .../src/dashboard/CompoundEngineeringView.tsx      |  40 +++++++-
 .../src/dashboard/__tests__/CeFlow.test.tsx        |  20 ++++
 .../__tests__/CompoundEngineeringView.test.tsx     |  53 ++++++++++-
 .../hooks/__tests__/useCeSessions.test.tsx         |  44 ++++++++-
 .../src/dashboard/hooks/api.ts                     |  10 ++
 .../src/dashboard/hooks/useCeSessions.ts           |  23 ++++-
 .../src/routes/session-routes.ts                   |  11 +++
 .../src/session/orchestrator.ts                    |  20 ++++
 14 files changed, 404 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-6250

Fusion-Task-Lineage: e9997056-364c-44e1-b846-00b08a1ff8fc
2026-06-11 22:42:21 -07:00

460 lines
16 KiB
TypeScript

import "./CompoundEngineeringView.css";
import { useCallback, useMemo, useState } from "react";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { PluginDashboardViewContext } from "@fusion/dashboard/app/plugins/types";
import { useArtifacts } from "./hooks/useArtifacts.js";
import { useViewportMode } from "./hooks/useViewportMode.js";
import { useCeSession, type CeSessionSubscribe } from "./hooks/useCeSession.js";
import { useCeSessions, type CeSessionsSubscribe } from "./hooks/useCeSessions.js";
import { CeFlow } from "./CeFlow.js";
import { getStage, listStages, type CeStageDefinition } from "../session/stage-registry.js";
import type { CeArtifactEntry, CeArtifactGroup } from "../artifacts/discovery.js";
import type { CeSession, CeSessionStatus } from "../session/session-store.js";
const CE_PLUGIN_ID = "fusion-plugin-compound-engineering";
/** Resolve a lucide icon name (from the registry) to a component, with fallback. */
function resolveIcon(name: string): LucideIcon {
const icons = LucideIcons as unknown as Record<string, LucideIcon>;
return icons[name] ?? LucideIcons.Circle;
}
/** Launcher: lists exactly the registered stages (R4) and launches one. */
function StageLauncher({
stages,
disabled,
onLaunch,
}: {
stages: CeStageDefinition[];
disabled: boolean;
onLaunch: (stage: CeStageDefinition) => void;
}) {
return (
<div className="ce-launcher card" data-testid="ce-launcher">
<h3>Start a stage</h3>
<ul className="ce-launcher-list">
{stages.map((stage) => {
const Icon = resolveIcon(stage.icon);
return (
<li key={stage.stageId}>
<button
type="button"
className="ce-launcher-tile btn"
data-testid="ce-launcher-stage"
data-stage={stage.stageId}
disabled={disabled}
onClick={() => onLaunch(stage)}
>
<Icon className="ce-launcher-icon" size={18} aria-hidden="true" />
<span className="ce-launcher-label">{stage.label}</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
/** Statuses that are settled (no agent turn in flight). */
const TERMINAL: ReadonlySet<CeSessionStatus> = new Set(["completed", "error", "interrupted"]);
function statusLabel(status: CeSessionStatus): string {
return status.replace("_", " ");
}
/**
* Sessions panel: every CE session (each an independent pipeline run) with its
* stage, status, and last activity — open any to keep working on it, discard
* settled ones. Sessions keep running server-side while not open here.
*/
function SessionsPanel({
sessions,
activeSessionId,
disabled,
onOpen,
onCancel,
onDiscard,
}: {
sessions: CeSession[];
activeSessionId?: string;
disabled: boolean;
onOpen: (session: CeSession) => void;
onCancel: (session: CeSession) => void;
onDiscard: (session: CeSession) => void;
}) {
if (sessions.length === 0) return null;
return (
<section className="ce-sessions card" data-testid="ce-sessions">
<header className="ce-group-header">
<h3>Sessions</h3>
<span className="ce-group-count">{sessions.length}</span>
</header>
<ul className="ce-sessions-list">
{sessions.map((s) => {
const stageLabel = getStage(s.stage)?.label ?? s.stage;
const awaiting = s.status === "awaiting_input";
return (
<li
key={s.id}
className={`ce-session-row${s.id === activeSessionId ? " is-active" : ""}`}
data-testid="ce-session-row"
data-session={s.id}
data-status={s.status}
>
<button
type="button"
className="ce-session-open"
data-testid="ce-session-open"
disabled={disabled}
onClick={() => onOpen(s)}
>
<span className="ce-session-stage">{stageLabel}</span>
<span className={`ce-session-status ce-session-status-${s.status}`} data-testid="ce-session-status">
{awaiting ? "needs your input" : statusLabel(s.status)}
</span>
<span className="ce-session-updated">{new Date(s.updatedAt).toLocaleString()}</span>
</button>
{TERMINAL.has(s.status) ? (
<button
type="button"
className="btn ce-session-discard"
data-testid="ce-session-discard"
disabled={disabled}
onClick={() => onDiscard(s)}
>
Discard
</button>
) : (
<button
type="button"
className="btn ce-session-cancel"
data-testid="ce-session-cancel"
disabled={disabled}
onClick={() => onCancel(s)}
>
Cancel
</button>
)}
</li>
);
})}
</ul>
</section>
);
}
interface CompoundEngineeringViewProps {
context?: PluginDashboardViewContext;
/** Test seam: override the active project id without a host context. */
projectId?: string;
/** Test seam: force the viewport-gated fetch on/off. */
enabledOverride?: boolean;
}
function readProjectId(props: CompoundEngineeringViewProps): string | undefined {
if (props.projectId) return props.projectId;
const ctx = props.context as { projectId?: string } | undefined;
return ctx?.projectId;
}
/** First-run / empty state: no artifacts AND no errors anywhere. */
function EmptyState({ onStart }: { onStart: () => void }) {
return (
<div className="ce-empty card" data-testid="ce-empty-state">
<h3>Start your compounding pipeline</h3>
<p>
No compound-engineering artifacts found yet. Compound Engineering tracks the documents your
pipeline produces — strategy, ideation, brainstorms, plans, solutions, and concepts — as you
move through each stage.
</p>
<p className="ce-empty-hint">
Begin with a stage and its artifact will appear here, grouped and traceable.
</p>
<button type="button" className="btn btn-primary" data-testid="ce-start-action" onClick={onStart}>
Start a stage
</button>
</div>
);
}
function ArtifactRow({
entry,
onSelect,
selected,
openFile,
}: {
entry: CeArtifactEntry;
onSelect: (id: string) => void;
selected: boolean;
openFile?: PluginDashboardViewContext["openFile"];
}) {
if (entry.kind === "error") {
return (
<li className="ce-artifact ce-artifact-error" data-testid="ce-artifact-error">
<span className="ce-artifact-name">{entry.name}</span>
<span className="ce-artifact-error-msg" role="alert">
Could not read: {entry.error}
</span>
<span className="ce-artifact-path">{entry.path}</span>
</li>
);
}
return (
<li className={`ce-artifact${selected ? " is-selected" : ""}`} data-testid="ce-artifact">
<button type="button" className="ce-artifact-btn" onClick={() => onSelect(entry.id)}>
<span className="ce-artifact-name">{entry.name}</span>
<span className="ce-artifact-path">{entry.path}</span>
</button>
<button
type="button"
className="ce-artifact-open"
data-testid="ce-artifact-open"
onClick={() => openFile?.(entry.path)}
>
Open
</button>
</li>
);
}
function StageGroup({
group,
onSelect,
selectedId,
openFile,
}: {
group: CeArtifactGroup;
onSelect: (id: string) => void;
selectedId?: string;
openFile?: PluginDashboardViewContext["openFile"];
}) {
const empty = group.entries.length === 0;
return (
<section className="ce-group" data-testid="ce-group" data-stage={group.stage} data-empty={empty ? "true" : "false"}>
<header className="ce-group-header">
<h3>{group.label}</h3>
<span className="ce-group-count">{group.entries.length}</span>
</header>
{empty ? (
<p className="ce-group-empty" data-testid="ce-group-empty">
No {group.label.toLowerCase()} artifacts yet.
</p>
) : (
<ul className="ce-artifact-list">
{group.entries.map((entry) => (
<ArtifactRow
key={entry.id}
entry={entry}
onSelect={onSelect}
selected={selectedId === entry.id}
openFile={openFile}
/>
))}
</ul>
)}
</section>
);
}
export function CompoundEngineeringView(props: CompoundEngineeringViewProps) {
const projectId = readProjectId(props);
const { mobile, active } = useViewportMode();
const enabled = props.enabledOverride ?? active;
const { result, loading, error } = useArtifacts({ projectId, enabled });
const [selectedId, setSelectedId] = useState<string | undefined>();
const stages = listStages();
// Live push: when the host forwards a plugin:custom SSE event for THIS session,
// refetch — lower latency than the poll fallback. Uses the host-provided
// subscribe capability (no raw EventSource, no deep dashboard import); when the
// host doesn't supply it, the hook falls back to polling.
const subscribePluginEvents = (props.context as PluginDashboardViewContext | undefined)
?.subscribePluginEvents;
const openFile = props.context?.openFile;
const subscribe = useMemo<CeSessionSubscribe | undefined>(() => {
if (!subscribePluginEvents) return undefined;
return (sessionId, _projectId, onSessionEvent) =>
subscribePluginEvents(CE_PLUGIN_ID, ({ payload }) => {
if ((payload as { sessionId?: string } | undefined)?.sessionId === sessionId) {
onSessionEvent();
}
});
}, [subscribePluginEvents]);
const ceSession = useCeSession(subscribe ? { subscribe } : {});
// Session list refresh: ANY CE push event means some session changed.
const subscribeList = useMemo<CeSessionsSubscribe | undefined>(() => {
if (!subscribePluginEvents) return undefined;
return (onAnyEvent) => subscribePluginEvents(CE_PLUGIN_ID, () => onAnyEvent());
}, [subscribePluginEvents]);
const ceSessions = useCeSessions({
projectId,
enabled,
...(subscribeList ? { subscribe: subscribeList } : {}),
});
const [launcherOpen, setLauncherOpen] = useState(false);
const [sessionActionBusy, setSessionActionBusy] = useState(false);
const totalArtifacts = result?.totalArtifacts ?? 0;
const totalErrors = result?.totalErrors ?? 0;
const hasAnything = totalArtifacts > 0 || totalErrors > 0;
// Partial discovery: at least one category populated AND at least one empty.
const populatedGroups = result?.groups.filter((g) => g.entries.length > 0).length ?? 0;
const emptyGroups = result?.groups.filter((g) => g.entries.length === 0).length ?? 0;
const isPartial = populatedGroups > 0 && emptyGroups > 0;
const onStart = () => setLauncherOpen(true);
const onLaunch = useCallback(
(stage: CeStageDefinition) => {
setLauncherOpen(false);
void ceSession
.start(stage.stageId, { message: `Start the ${stage.label} stage.`, projectId })
.then(() => ceSessions.refresh());
},
[ceSession, ceSessions, projectId],
);
const onOpenSession = useCallback(
(s: CeSession) => {
void ceSession.open(s.id, { projectId });
},
[ceSession, projectId],
);
const onCancelSession = useCallback(
(s: CeSession) => {
setSessionActionBusy(true);
void ceSessions
.cancel(s.id)
.then(() => {
if (ceSession.session?.id === s.id) ceSession.reset();
})
.finally(() => setSessionActionBusy(false));
},
[ceSession, ceSessions],
);
const onDiscardSession = useCallback(
(s: CeSession) => {
setSessionActionBusy(true);
void ceSessions.remove(s.id).finally(() => setSessionActionBusy(false));
},
[ceSessions],
);
// Closing the flow returns to the overview WITHOUT stopping the session —
// it keeps running server-side and stays reachable from the sessions panel.
const onCloseFlow = useCallback(() => {
ceSession.reset();
void ceSessions.refresh();
}, [ceSession, ceSessions]);
// Once a session is active here, the flow renderer owns the surface until
// closed — but the sessions panel stays visible so other sessions remain
// one click away (switching does not stop the open one).
if (ceSession.session) {
return (
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
<div className="ce-view-header">
<h2>Compound Engineering</h2>
</div>
<SessionsPanel
sessions={ceSessions.sessions}
activeSessionId={ceSession.session.id}
disabled={ceSession.busy || sessionActionBusy}
onOpen={onOpenSession}
onCancel={onCancelSession}
onDiscard={onDiscardSession}
/>
<CeFlow
session={ceSession.session}
busy={ceSession.busy || sessionActionBusy}
error={ceSession.error}
onAnswer={ceSession.answer}
onResume={ceSession.resume}
onCancel={() => onCancelSession(ceSession.session!)}
onClose={onCloseFlow}
/>
</div>
);
}
return (
<div className="ce-view" data-testid="compound-engineering-view" data-mobile={mobile ? "true" : "false"}>
<div className="ce-view-header">
<h2>Compound Engineering</h2>
{hasAnything ? (
<span className="ce-view-summary" data-testid="ce-summary">
{totalArtifacts} artifact{totalArtifacts === 1 ? "" : "s"}
{totalErrors > 0 ? ` · ${totalErrors} unreadable` : ""}
{isPartial ? " · partial" : ""}
</span>
) : null}
{hasAnything ? (
<button type="button" className="btn btn-primary ce-view-start" data-testid="ce-start-action-header" onClick={onStart}>
Start a stage
</button>
) : null}
</div>
{launcherOpen ? (
<StageLauncher stages={stages} disabled={ceSession.busy} onLaunch={onLaunch} />
) : null}
<SessionsPanel
sessions={ceSessions.sessions}
disabled={ceSession.busy || sessionActionBusy}
onOpen={onOpenSession}
onCancel={onCancelSession}
onDiscard={onDiscardSession}
/>
{ceSessions.error ? (
<div className="ce-view-error card" role="alert" data-testid="ce-sessions-error">
Failed to load sessions: {ceSessions.error}
</div>
) : null}
{ceSession.error && !ceSession.session ? (
<div className="ce-view-error card" role="alert" data-testid="ce-session-error">
Failed to start session: {ceSession.error}
</div>
) : null}
{error ? (
<div className="ce-view-error card" role="alert" data-testid="ce-fetch-error">
Failed to load artifacts: {error}
</div>
) : null}
{loading && !result ? (
<div className="ce-loading" data-testid="ce-loading">
Discovering artifacts…
</div>
) : null}
{result && !hasAnything ? (
<EmptyState onStart={onStart} />
) : null}
{result && hasAnything ? (
<div className="ce-groups" data-partial={isPartial ? "true" : "false"}>
{result.groups.map((group) => (
<StageGroup
key={group.stage}
group={group}
onSelect={setSelectedId}
selectedId={selectedId}
openFile={openFile}
/>
))}
</div>
) : null}
</div>
);
}
export default CompoundEngineeringView;