feat: add demo timeline replay controls

This commit is contained in:
lda
2026-07-03 03:08:21 +07:00 Verified
parent 7dd1dc86d8
commit 2ade72f1db
22 changed files with 3817 additions and 596 deletions
+3 -1
View File
@@ -61,9 +61,11 @@ Implementation order:
5. Completed: the web console can operate the prepared 5. Completed: the web console can operate the prepared
`examples/lda_report_workflow/` deployment through run start, typed `examples/lda_report_workflow/` deployment through run start, typed
`issue_review` interrupt, resume, trace, and final output inspection. `issue_review` interrupt, resume, trace, and final output inspection.
6. Add lifecycle autoplay, typed approval, issue-board output, and replay. 6. Completed: lifecycle autoplay, typed approval, issue-board output, and replay.
Design: Design:
[`demo autoplay and replay`](superpowers/specs/2026-07-03-demo-autoplay-replay.md). [`demo autoplay and replay`](superpowers/specs/2026-07-03-demo-autoplay-replay.md).
Implementation:
[`demo autoplay and replay plan`](historical/superpowers/plans/2026-07-03-demo-autoplay-replay.md).
7. Add a constrained demo agent that invokes one prepared recipe macro. 7. Add a constrained demo agent that invokes one prepared recipe macro.
8. Add an Astro presentation app and appendix routes for the 15-minute defense. 8. Add an Astro presentation app and appendix routes for the 15-minute defense.
File diff suppressed because it is too large Load Diff
+11
View File
@@ -137,3 +137,14 @@ Open `http://127.0.0.1:5173/`, connect to
panel. The panel expects `lda_report_case_study.default` to already exist panel. The panel expects `lda_report_case_study.default` to already exist
in the connected store. If it is missing, the panel displays the exact in the connected store. If it is missing, the panel displays the exact
product CLI setup commands. product CLI setup commands.
### Demo Timeline Modes
- **Live** executes the prepared deployment through public JSON-RPC calls.
- **Replay** uses the committed `lda-report-success-v1` recording and does not
contact the workflow server during playback.
`Start presentation` begins autoplay. `Pause` stops before the next
operation, and `Next` applies exactly one operation or recorded event.
Playback always stops at `issue_review`; approval remains a human action in
both modes. Replay is visibly labeled and does not create real issues.
+33 -20
View File
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { App } from "./App.js"; import { App } from "./App.js";
import { callOperation, connectToServer } from "../connection/api.js"; import { callOperation, connectToServer } from "../connection/api.js";
import type { ConnectResponse, RpcResponse } from "../connection/contracts.js"; import type { RpcResponse } from "../connection/contracts.js";
vi.mock("../connection/api.js", () => ({ vi.mock("../connection/api.js", () => ({
connectToServer: vi.fn(), connectToServer: vi.fn(),
@@ -13,12 +13,12 @@ vi.mock("../connection/api.js", () => ({
const mockedConnectToServer = vi.mocked(connectToServer); const mockedConnectToServer = vi.mocked(connectToServer);
const mockedCallOperation = vi.mocked(callOperation); const mockedCallOperation = vi.mocked(callOperation);
const successfulConnection = (target: string): ConnectResponse => ({ const successfulConnection = (target: string) => ({
ok: true, ok: true as const,
connection: { connection: {
status: "connected", status: "connected" as const,
target, target,
serverStatus: "ok", serverStatus: "ok" as const,
storeRoot: "/tmp/store", storeRoot: "/tmp/store",
durationMs: 11, durationMs: 11,
}, },
@@ -74,9 +74,9 @@ afterEach(() => {
cleanup(); cleanup();
}); });
const lifecycleOk: RpcResponse = { const lifecycleOk = {
ok: true, ok: true as const,
operation: "workflow.artifacts.list", operation: "workflow.artifacts.list" as const,
label: "List artifacts", label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null }, interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} }, exchange: { request: {}, response: {} },
@@ -102,15 +102,6 @@ describe("App", () => {
it("ignores stale source inventory responses after reconnect", async () => { it("ignores stale source inventory responses after reconnect", async () => {
const firstSources = deferred<RpcResponse>(); const firstSources = deferred<RpcResponse>();
const secondSources = deferred<RpcResponse>(); const secondSources = deferred<RpcResponse>();
const lifecycleOk: RpcResponse = {
ok: true,
operation: "workflow.artifacts.list",
label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
};
let latestSourcesDeferred = firstSources; let latestSourcesDeferred = firstSources;
mockedConnectToServer mockedConnectToServer
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc")) .mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
@@ -143,8 +134,8 @@ describe("App", () => {
mockedCallOperation.mockImplementation((op: string) => { mockedCallOperation.mockImplementation((op: string) => {
if (op === "workflow.sources.list") { if (op === "workflow.sources.list") {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true as const,
operation: "workflow.sources.list", operation: "workflow.sources.list" as const,
label: "List sources", label: "List sources",
interpreted: { sources: [], total: 0, nextCursor: null }, interpreted: { sources: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} }, exchange: { request: {}, response: {} },
@@ -154,7 +145,7 @@ describe("App", () => {
} }
if (op === "workflow.deployments.inspect") { if (op === "workflow.deployments.inspect") {
return Promise.resolve({ return Promise.resolve({
ok: false, ok: false as const,
error: { code: "rpc_remote_error", message: "not found" }, error: { code: "rpc_remote_error", message: "not found" },
exchange: { request: {}, response: {} }, exchange: { request: {}, response: {} },
}); });
@@ -170,4 +161,26 @@ describe("App", () => {
}); });
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument(); expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
}); });
it("always renders demo panel even without connection", async () => {
mockedConnectToServer.mockRejectedValue(new Error("connection refused"));
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(screen.getByLabelText("lda report workflow demo")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
});
it("shows replay mode button even without connection", async () => {
render(<App />);
expect(screen.getByRole("button", { name: "Replay" })).toBeVisible();
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Replay" }));
expect(screen.getByRole("button", { name: /start presentation/i })).toBeEnabled();
});
}); });
+3 -3
View File
@@ -11,7 +11,7 @@ import { SourceInventory } from "../components/SourceInventory.js";
import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js"; import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js"; import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js";
import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js"; import { LdaReportDemoPanel } from "../demo/LdaReportDemoPanel.js";
import { useLdaReportDemo } from "../demo/useLdaReportDemo.js"; import { useDemoTimeline } from "../demo/useDemoTimeline.js";
const parseSources = ( const parseSources = (
data: unknown, data: unknown,
@@ -56,7 +56,7 @@ export const App = () => {
); );
const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence); const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence);
const demoController = useLdaReportDemo(connectedTarget, recordEvidence); const demoController = useDemoTimeline(connectedTarget, recordEvidence);
const loadSources = useCallback( const loadSources = useCallback(
async (target: string) => { async (target: string) => {
@@ -163,7 +163,7 @@ export const App = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
onDraftChange={(value) => dispatch({ type: "draft_changed", value })} onDraftChange={(value) => dispatch({ type: "draft_changed", value })}
/> />
{connectedTarget && <LdaReportDemoPanel controller={demoController} />} <LdaReportDemoPanel controller={demoController} />
<SourceInventory <SourceInventory
sources={state.sources} sources={state.sources}
loading={state.sourcesLoading} loading={state.sourcesLoading}
@@ -0,0 +1,28 @@
import type { DemoTimelineState } from "./timeline/reducer.js";
type DemoTimelineProps = {
readonly state: DemoTimelineState;
};
export const DemoTimeline = ({ state }: DemoTimelineProps) => {
if (state.events.length === 0) return null;
return (
<ol className="demo-timeline" aria-label="Demo timeline">
{state.events.map((event, index) => {
const status =
index < state.appliedCount
? "complete"
: index === state.appliedCount
? "current"
: "pending";
return (
<li key={event.id} data-status={status}>
<span>{event.stage.replaceAll("_", " ")}</span>
<small>{event.reason}</small>
</li>
);
})}
</ol>
);
};
@@ -0,0 +1,60 @@
import type { DemoTimelineController } from "./useDemoTimeline.js";
import type { DemoMode } from "./timeline/reducer.js";
type DemoTimelineControlsProps = Pick<
DemoTimelineController,
"state" | "inFlight" | "canStart" | "setMode" | "start" | "pause" | "play" | "next" | "restart"
>;
export const DemoTimelineControls = ({
state,
inFlight,
canStart,
setMode,
start,
pause,
play,
next,
restart,
}: DemoTimelineControlsProps) => {
const modeDisabled = state.phase !== "ready";
const inRunning = state.phase === "running";
return (
<div className="demo-timeline-controls">
<div className="demo-mode-switch">
<button
onClick={() => setMode("live")}
disabled={modeDisabled}
aria-pressed={state.mode === "live"}
>
Live
</button>
<button
onClick={() => setMode("replay")}
disabled={modeDisabled}
aria-pressed={state.mode === "replay"}
>
Replay
</button>
</div>
<div className="demo-playback-controls">
{state.phase === "ready" && (
<button onClick={start} disabled={!canStart || inFlight}>Start presentation</button>
)}
{inRunning && (
<button onClick={pause} disabled={inFlight}>Pause</button>
)}
{state.phase === "paused" && (
<>
<button onClick={play} disabled={inFlight}>Play</button>
<button onClick={() => void next()} disabled={inFlight}>Next</button>
</>
)}
{(state.phase === "completed" || state.phase === "failed") && (
<button onClick={restart}>Restart</button>
)}
</div>
</div>
);
};
@@ -1,26 +1,43 @@
import { render, screen, within } from "@testing-library/react"; import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest"; import { afterEach, describe, it, expect, vi } from "vitest";
import { LdaReportDemoPanel } from "./LdaReportDemoPanel.js"; import { LdaReportDemoPanel } from "./LdaReportDemoPanel.js";
const baseState = { afterEach(() => cleanup());
message: null as string | null,
runId: null as string | null, const baseController = {
interruptPayload: null as null, state: {
output: null as null, mode: "live" as const,
trace: null as null, phase: "ready" as const,
events: [],
appliedCount: 0,
autoplay: false,
error: null,
},
inFlight: false,
interruptPayload: null,
output: null,
trace: null,
missingDeploymentMessage: null,
recordingId: null,
canStart: true,
setMode: vi.fn(),
start: vi.fn(),
pause: vi.fn(),
play: vi.fn(),
next: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
restart: vi.fn(),
}; };
describe("LdaReportDemoPanel", () => { describe("LdaReportDemoPanel", () => {
it("shows setup commands when the prepared deployment is missing", () => { it("shows setup commands when live mode has no connection", () => {
render( render(
<LdaReportDemoPanel <LdaReportDemoPanel
controller={{ controller={{
state: { ...baseState, phase: "missing" }, ...baseController,
refresh: vi.fn(), missingDeploymentMessage: "Not connected. Select Live and connect to a workflow server, or switch to Replay.",
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}} }}
/>, />,
); );
@@ -29,109 +46,83 @@ describe("LdaReportDemoPanel", () => {
expect(screen.getByText(/wf-rpc-server --config examples\/lda_report_workflow\/wf.config.json/i)).toBeInTheDocument(); expect(screen.getByText(/wf-rpc-server --config examples\/lda_report_workflow\/wf.config.json/i)).toBeInTheDocument();
}); });
it("starts the demo when ready", async () => { it("starts a connected live demo when Start presentation is clicked", async () => {
const startRun = vi.fn(); const start = vi.fn();
render(
<LdaReportDemoPanel
controller={{ ...baseController, start, canStart: true }}
/>,
);
await userEvent.click(screen.getByRole("button", { name: /start presentation/i }));
expect(start).toHaveBeenCalledOnce();
});
it("disables live start without a connection but allows offline replay", () => {
const { rerender } = render(
<LdaReportDemoPanel
controller={{
...baseController,
canStart: false,
missingDeploymentMessage: "Not connected.",
}}
/>,
);
expect(screen.getByRole("button", { name: /start presentation/i })).toBeDisabled();
rerender(
<LdaReportDemoPanel
controller={{
...baseController,
canStart: true,
state: { ...baseController.state, mode: "replay" },
recordingId: "lda-report-success-v1",
}}
/>,
);
expect(screen.getByRole("button", { name: /start presentation/i })).toBeEnabled();
});
it("shows replay attribution when in replay mode", () => {
render( render(
<LdaReportDemoPanel <LdaReportDemoPanel
controller={{ controller={{
state: { ...baseState, phase: "ready" }, ...baseController,
refresh: vi.fn(), state: { ...baseController.state, mode: "replay" },
startRun, recordingId: "lda-report-success-v1",
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}} }}
/>, />,
); );
await userEvent.click(screen.getByRole("button", { name: /start demo run/i })); expect(screen.getByText(/recorded replay/i)).toBeInTheDocument();
expect(startRun).toHaveBeenCalledOnce(); expect(screen.getByText(/lda-report-success-v1/)).toBeInTheDocument();
});
it("disables refresh during interrupt to preserve runId", () => {
const { container } = render(
<LdaReportDemoPanel
controller={{
state: {
...baseState,
phase: "interrupted",
runId: "run_demo",
interruptPayload: {
report_markdown: "# Report",
proposed_issues: [],
},
},
refresh: vi.fn(),
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
const buttons = container.querySelectorAll("button");
const refreshButton = Array.from(buttons).find(
(b) => b.textContent?.includes("Refresh demo state"),
);
expect(refreshButton).toBeDefined();
expect(refreshButton).toBeDisabled();
});
it("allows refresh after completion so the demo can run again", () => {
const view = render(
<LdaReportDemoPanel
controller={{
state: {
...baseState,
phase: "completed",
output: {
approved: true,
markdown: "# Report",
created_issues: [],
selected_issue_ids: [],
comment: null,
},
},
refresh: vi.fn(),
startRun: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
}}
/>,
);
expect(
within(view.container).getByRole("button", { name: /refresh demo state/i }),
).toBeEnabled();
}); });
it("displays trace frames in completed view", () => { it("displays trace frames in completed view", () => {
render( render(
<LdaReportDemoPanel <LdaReportDemoPanel
controller={{ controller={{
...baseController,
state: { state: {
...baseState, ...baseController.state,
phase: "completed", phase: "completed",
output: {
approved: true,
markdown: "# Report",
created_issues: [{ id: "ISSUE-001", title: "Demo", url: "local://issues/ISSUE-001" }],
selected_issue_ids: ["demo-issue-1"],
comment: "Create it.",
},
trace: {
frames: [
{ nodeId: "generate", stepType: "tool", outcome: "completed", resolvedInput: {}, output: {}, stateChanges: {} },
{ nodeId: "review", stepType: "interrupt", outcome: "submitted", resolvedInput: {}, output: {}, stateChanges: {} },
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
}, },
refresh: vi.fn(), output: {
startRun: vi.fn(), approved: true,
submitSelectedIssues: vi.fn(), markdown: "# Report",
cancelReview: vi.fn(), created_issues: [{ id: "ISSUE-001", title: "Demo", url: "local://issues/ISSUE-001" }],
selected_issue_ids: ["demo-issue-1"],
comment: "Create it.",
},
trace: {
frames: [
{ nodeId: "generate", stepType: "tool", outcome: "completed", resolvedInput: {}, output: {}, stateChanges: {} },
{ nodeId: "review", stepType: "interrupt", outcome: "submitted", resolvedInput: {}, output: {}, stateChanges: {} },
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
}} }}
/>, />,
); );
@@ -140,4 +131,23 @@ describe("LdaReportDemoPanel", () => {
expect(screen.getByText("generate")).toBeInTheDocument(); expect(screen.getByText("generate")).toBeInTheDocument();
expect(screen.getByText("review")).toBeInTheDocument(); expect(screen.getByText("review")).toBeInTheDocument();
}); });
it("shows Continue button in replay review mode", () => {
render(
<LdaReportDemoPanel
controller={{
...baseController,
state: { ...baseController.state, mode: "replay", phase: "review" },
interruptPayload: {
report_markdown: "# Report",
proposed_issues: [{ id: "risk-1", title: "Defense", body: "Review paths.", severity: "medium" }],
},
recordingId: "lda-report-success-v1",
}}
/>,
);
expect(screen.getByRole("button", { name: /continue/i })).toBeInTheDocument();
expect(screen.getByText(/replay does not create real issues/i)).toBeInTheDocument();
});
}); });
@@ -1,21 +1,16 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js"; import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
import type { useLdaReportDemo } from "./useLdaReportDemo.js"; import { DemoTimelineControls } from "./DemoTimelineControls.js";
import { DemoTimeline } from "./DemoTimeline.js";
import type { DemoTimelineController } from "./useDemoTimeline.js";
type Controller = ReturnType<typeof useLdaReportDemo>; export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => {
export const LdaReportDemoPanel = ({ controller }: { readonly controller: Controller }) => {
const { state } = controller; const { state } = controller;
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
const [comment, setComment] = useState("Create selected issues before the defense."); const [comment, setComment] = useState("Create selected issues before the defense.");
const proposedIssues = state.interruptPayload?.proposed_issues ?? []; const proposedIssues = controller.interruptPayload?.proposed_issues ?? [];
const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]); const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
const runInProgress =
state.phase === "starting" ||
state.phase === "interrupted" ||
state.phase === "resuming";
return ( return (
<section aria-label="lda report workflow demo" className="demo-panel"> <section aria-label="lda report workflow demo" className="demo-panel">
<div className="demo-panel__header"> <div className="demo-panel__header">
@@ -26,12 +21,9 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: Contro
resume, then inspect trace and generated issues. resume, then inspect trace and generated issues.
</p> </p>
</div> </div>
<button onClick={controller.refresh} disabled={runInProgress}>
Refresh demo state
</button>
</div> </div>
{state.phase === "missing" && ( {state.mode === "live" && controller.missingDeploymentMessage && (
<div className="demo-panel__missing" role="status"> <div className="demo-panel__missing" role="status">
<h3>Prepared demo deployment is missing</h3> <h3>Prepared demo deployment is missing</h3>
<p>Run the example RPC server/store setup outside the UI, then refresh.</p> <p>Run the example RPC server/store setup outside the UI, then refresh.</p>
@@ -39,26 +31,36 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: Contro
</div> </div>
)} )}
{(state.phase === "ready" || state.phase === "checking") && ( <DemoTimelineControls
<button state={state}
onClick={controller.startRun} inFlight={controller.inFlight}
disabled={state.phase === "checking"} canStart={controller.canStart}
> setMode={controller.setMode}
Start demo run start={controller.start}
</button> pause={controller.pause}
play={controller.play}
next={controller.next}
restart={controller.restart}
/>
{state.mode === "replay" && (
<p className="demo-replay-label" role="status">
Recorded replay &middot; {controller.recordingId}
</p>
)} )}
{(state.phase === "starting" || state.phase === "resuming") && ( <DemoTimeline state={state} />
{(state.phase === "running" || state.phase === "paused") && (
<p role="status">Demo workflow is {state.phase}.</p> <p role="status">Demo workflow is {state.phase}.</p>
)} )}
{state.phase === "interrupted" && state.interruptPayload && ( {state.phase === "review" && controller.interruptPayload && (
<div className="demo-panel__review"> <div className="demo-panel__review">
<h3>Typed interrupt: issue_review</h3> <h3>Typed interrupt: issue_review</h3>
<p>Run id: <code>{state.runId}</code></p>
<div className="demo-panel__markdown"> <div className="demo-panel__markdown">
<h4>Generated report preview</h4> <h4>Generated report preview</h4>
<pre><code>{state.interruptPayload.report_markdown}</code></pre> <pre><code>{controller.interruptPayload.report_markdown}</code></pre>
</div> </div>
<fieldset> <fieldset>
<legend>Select issues to create</legend> <legend>Select issues to create</legend>
@@ -79,7 +81,7 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: Contro
/> />
<span> <span>
<strong>{issue.title}</strong> <strong>{issue.title}</strong>
<small>{issue.id} · {issue.severity}</small> <small>{issue.id} &middot; {issue.severity}</small>
<span>{issue.body}</span> <span>{issue.body}</span>
</span> </span>
</label> </label>
@@ -90,34 +92,48 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: Contro
<textarea value={comment} onChange={(event) => setComment(event.currentTarget.value)} /> <textarea value={comment} onChange={(event) => setComment(event.currentTarget.value)} />
</label> </label>
<div className="demo-panel__actions"> <div className="demo-panel__actions">
<button {state.mode === "replay" ? (
onClick={() => controller.submitSelectedIssues(selectedIssueIds, comment)} <button
disabled={selectedIssueIds.length === 0} onClick={() => void controller.submitSelectedIssues(selectedIssueIds, comment)}
> disabled={selectedIssueIds.length === 0}
Resume and create selected issues >
</button> Continue
<button onClick={() => controller.cancelReview(comment)}> </button>
) : (
<button
onClick={() => void controller.submitSelectedIssues(selectedIssueIds, comment)}
disabled={selectedIssueIds.length === 0}
>
Resume and create selected issues
</button>
)}
<button onClick={() => void controller.cancelReview(comment)}>
Cancel review Cancel review
</button> </button>
</div> </div>
{state.mode === "replay" && (
<p className="demo-replay-note" role="note">
Replay does not create real issues.
</p>
)}
</div> </div>
)} )}
{state.phase === "completed" && state.output && ( {state.phase === "completed" && controller.output && (
<div className="demo-panel__complete"> <div className="demo-panel__complete">
<h3>Completed: {state.output.approved ? "issues created" : "revision requested"}</h3> <h3>Completed: {controller.output.approved ? "issues created" : "revision requested"}</h3>
<p>Created issues: {state.output.created_issues.length}</p> <p>Created issues: {controller.output.created_issues.length}</p>
<ul> <ul>
{state.output.created_issues.map((issue) => ( {controller.output.created_issues.map((issue) => (
<li key={issue.id}> <li key={issue.id}>
<strong>{issue.id}</strong> {issue.title} <strong>{issue.id}</strong> {issue.title}
</li> </li>
))} ))}
</ul> </ul>
<h4>Final markdown</h4> <h4>Final markdown</h4>
<pre><code>{state.output.markdown}</code></pre> <pre><code>{controller.output.markdown}</code></pre>
<h4>Execution trace ({state.trace?.frames.length ?? 0} frames)</h4> <h4>Execution trace ({controller.trace?.frames.length ?? 0} frames)</h4>
{state.trace && state.trace.frames.length > 0 ? ( {controller.trace && controller.trace.frames.length > 0 ? (
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -127,7 +143,7 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: Contro
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{state.trace.frames.map((frame, i) => ( {controller.trace.frames.map((frame, i) => (
<tr key={i}> <tr key={i}>
<td><code>{frame.nodeId}</code></td> <td><code>{frame.nodeId}</code></td>
<td>{frame.stepType}</td> <td>{frame.stepType}</td>
@@ -142,8 +158,8 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: Contro
</div> </div>
)} )}
{state.phase === "error" && state.message && ( {state.phase === "failed" && state.error && (
<p role="alert">{state.message}</p> <p role="alert">{state.error}</p>
)} )}
</section> </section>
); );
@@ -0,0 +1,320 @@
{
"schemaVersion": 1,
"recordingId": "lda-report-success-v1",
"title": "lda.chat report workflow success",
"createdAt": "2026-07-03T00:00:00.000Z",
"deploymentId": "lda_report_case_study.default",
"source": "reviewed_live_capture",
"events": [
{
"id": "recorded-0-deployment-check",
"sequence": 0,
"stage": "deployment_check",
"operation": "workflow.deployments.inspect",
"reason": "Confirm the prepared report deployment exists.",
"equivalentCli": "uv run wf deploy inspect lda_report_case_study.default",
"params": { "deployment_id": "lda_report_case_study.default" },
"rawResponse": {
"result": {
"id": "lda_report_case_study.default",
"artifact_id": "lda_report_case_study",
"artifact_version": 1,
"bindings": {
"local.lda_docs": "local.lda_docs",
"local.lda_report": "local.lda_report",
"local.issue_board": "local.issue_board"
},
"drift_policy": "block"
}
},
"interpreted": {
"id": "lda_report_case_study.default",
"artifactId": "lda_report_case_study",
"artifactVersion": 1,
"bindings": [
["local.lda_docs", "local.lda_docs"],
["local.lda_report", "local.lda_report"],
["local.issue_board", "local.issue_board"]
],
"driftPolicy": "block"
},
"durationMs": 6,
"resultingIds": {
"deploymentId": "lda_report_case_study.default",
"runId": null
},
"recordedAt": "2026-07-03T00:00:00.000Z"
},
{
"id": "recorded-1-run-start",
"sequence": 1,
"stage": "run_start",
"operation": "workflow.runs.start",
"reason": "Start the prepared report workflow.",
"equivalentCli": "uv run wf run start lda_report_case_study.default --input '<json>'",
"params": {
"deployment_id": "lda_report_case_study.default",
"workflow_input": {
"selected_documents": [
"project-brief.md",
"architecture-notes.md",
"evaluation-findings.md",
"risk-register.md",
"roadmap.md"
],
"board_path": "issue-board.json"
},
"trace_range": { "start": 0, "limit": 50 }
},
"rawResponse": { "result": { "run_id": "run_recorded_lda_report", "status": "interrupted" } },
"interpreted": {
"runId": "run_recorded_lda_report",
"deploymentId": "lda_report_case_study.default",
"artifactId": "lda_report_case_study",
"artifactVersion": 1,
"status": "interrupted",
"resumeReadiness": "ready",
"interrupt": {
"kind": "issue_review",
"payload": {
"report_markdown": "# lda.chat Thesis And Project Readiness Report\n\nThe workflow substrate is ready for the defense demo.",
"proposed_issues": [
{
"id": "risk-1",
"title": "Prepare the defense walkthrough",
"body": "Review the live and replay paths before the defense.",
"severity": "medium"
}
]
},
"outcomes": ["submitted", "cancelled"],
"typed": true,
"request_schema": { "type": "object" },
"resume_schema": { "type": "object" }
},
"outcome": null,
"error": null,
"output": null,
"diagnostics": [],
"traceCount": 6,
"nextActions": {
"canContinue": true,
"canSaveNow": null,
"recommendedNextTool": "wf.workflow.resume_run",
"reason": "Run is interrupted for issue review.",
"patchExamples": [],
"warnings": []
}
},
"durationMs": 88,
"resultingIds": {
"deploymentId": "lda_report_case_study.default",
"runId": "run_recorded_lda_report"
},
"recordedAt": "2026-07-03T00:00:01.000Z"
},
{
"id": "recorded-2-interrupt",
"sequence": 2,
"stage": "interrupt",
"operation": null,
"reason": "Pause for typed issue review.",
"equivalentCli": null,
"params": {},
"rawResponse": null,
"interpreted": {
"payload": {
"report_markdown": "# lda.chat Thesis And Project Readiness Report\n\nThe workflow substrate is ready for the defense demo.",
"proposed_issues": [
{
"id": "risk-1",
"title": "Prepare the defense walkthrough",
"body": "Review the live and replay paths before the defense.",
"severity": "medium"
}
]
},
"outcomes": ["submitted", "cancelled"]
},
"durationMs": 0,
"resultingIds": {
"deploymentId": "lda_report_case_study.default",
"runId": "run_recorded_lda_report"
},
"recordedAt": "2026-07-03T00:00:01.001Z"
},
{
"id": "recorded-3-run-resume",
"sequence": 3,
"stage": "run_resume",
"operation": "workflow.runs.resume",
"reason": "Resume the interrupted run.",
"equivalentCli": "uv run wf run resume run_recorded_lda_report --payload '<json>'",
"params": {
"run_id": "run_recorded_lda_report",
"resume_payload": {
"approved": true,
"selected_issue_ids": ["risk-1"],
"comment": "Create the selected issue."
},
"resume_outcome": "submitted",
"trace_range": { "start": 0, "limit": 50 }
},
"rawResponse": { "result": { "run_id": "run_recorded_lda_report", "status": "completed" } },
"interpreted": {
"runId": "run_recorded_lda_report",
"deploymentId": "lda_report_case_study.default",
"artifactId": "lda_report_case_study",
"artifactVersion": 1,
"status": "completed",
"resumeReadiness": "not_applicable",
"interrupt": null,
"outcome": "completed",
"error": null,
"output": {
"approved": true,
"markdown": "# lda.chat Thesis And Project Readiness Report\n\nThe workflow substrate is ready for the defense demo.",
"created_issues": [
{
"id": "ISSUE-001",
"title": "Prepare the defense walkthrough",
"url": "local://issue-board/ISSUE-001"
}
],
"selected_issue_ids": ["risk-1"],
"comment": "Create the selected issue."
},
"diagnostics": [],
"traceCount": 10,
"nextActions": {
"canContinue": false,
"canSaveNow": null,
"recommendedNextTool": null,
"reason": "Run completed.",
"patchExamples": [],
"warnings": []
}
},
"durationMs": 63,
"resultingIds": {
"deploymentId": "lda_report_case_study.default",
"runId": "run_recorded_lda_report"
},
"recordedAt": "2026-07-03T00:00:02.000Z"
},
{
"id": "recorded-4-trace-read",
"sequence": 4,
"stage": "trace_read",
"operation": "workflow.runs.trace",
"reason": "Read the final run trace.",
"equivalentCli": "uv run wf run trace run_recorded_lda_report --from 0 --limit 50",
"params": {
"run_id": "run_recorded_lda_report",
"trace_range": { "start": 0, "limit": 50 }
},
"rawResponse": { "result": { "run_id": "run_recorded_lda_report", "trace_count": 3 } },
"interpreted": {
"runId": "run_recorded_lda_report",
"status": "completed",
"frames": [
{
"nodeId": "list_documents",
"stepType": "node",
"outcome": "ok",
"resolvedInput": {},
"output": {},
"stateChanges": {}
},
{
"nodeId": "review_issues",
"stepType": "interrupt",
"outcome": "submitted",
"resolvedInput": {},
"output": {},
"stateChanges": {}
},
{
"nodeId": "finalise_report",
"stepType": "node",
"outcome": "completed",
"resolvedInput": {},
"output": {},
"stateChanges": {}
}
],
"traceStart": 0,
"traceLimit": 50,
"traceTruncated": false
},
"durationMs": 12,
"resultingIds": {
"deploymentId": "lda_report_case_study.default",
"runId": "run_recorded_lda_report"
},
"recordedAt": "2026-07-03T00:00:03.000Z"
},
{
"id": "recorded-5-completed",
"sequence": 5,
"stage": "completed",
"operation": null,
"reason": "The prepared report demo completed.",
"equivalentCli": null,
"params": {},
"rawResponse": null,
"interpreted": {
"output": {
"approved": true,
"markdown": "# lda.chat Thesis And Project Readiness Report\n\nThe workflow substrate is ready for the defense demo.",
"created_issues": [
{
"id": "ISSUE-001",
"title": "Prepare the defense walkthrough",
"url": "local://issue-board/ISSUE-001"
}
],
"selected_issue_ids": ["risk-1"],
"comment": "Create the selected issue."
},
"trace": {
"frames": [
{
"nodeId": "list_documents",
"stepType": "node",
"outcome": "ok",
"resolvedInput": {},
"output": {},
"stateChanges": {}
},
{
"nodeId": "review_issues",
"stepType": "interrupt",
"outcome": "submitted",
"resolvedInput": {},
"output": {},
"stateChanges": {}
},
{
"nodeId": "finalise_report",
"stepType": "node",
"outcome": "completed",
"resolvedInput": {},
"output": {},
"stateChanges": {}
}
],
"traceStart": 0,
"traceLimit": 50,
"traceTruncated": false
}
},
"durationMs": 0,
"resultingIds": {
"deploymentId": "lda_report_case_study.default",
"runId": "run_recorded_lda_report"
},
"recordedAt": "2026-07-03T00:00:03.001Z"
}
]
}
@@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { callOperation } from "../../connection/api.js";
import {
executeLiveDemoStep,
initialLiveDemoContext,
} from "./live.js";
vi.mock("../../connection/api.js", () => ({ callOperation: vi.fn() }));
const mockedCallOperation = vi.mocked(callOperation);
beforeEach(() => mockedCallOperation.mockReset());
const interruptedStartResult = {
ok: true as const,
operation: "workflow.runs.start" as const,
label: "Start run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
interrupt: {
kind: "issue_review",
payload: {
report_markdown: "# Report",
proposed_issues: [
{ id: "risk-1", title: "Prepare defense", body: "Review paths.", severity: "medium" },
],
},
outcomes: ["submitted", "cancelled"],
typed: true,
request_schema: { type: "object" },
resume_schema: { type: "object" },
},
outcome: null,
error: null,
output: null,
diagnostics: [],
traceCount: 6,
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: "wf.workflow.resume_run",
reason: "Run is interrupted for issue review.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 88,
};
const completedResumeResult = {
ok: true as const,
operation: "workflow.runs.resume" as const,
label: "Resume run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "completed",
error: null,
output: {
approved: true,
markdown: "# Report",
created_issues: [{ id: "ISSUE-001", title: "Defense", url: "local://issues/ISSUE-001" }],
selected_issue_ids: ["risk-1"],
comment: "Create it.",
},
diagnostics: [],
traceCount: 10,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
durationMs: 63,
};
const traceResult = {
ok: true as const,
operation: "workflow.runs.trace" as const,
label: "Read trace",
interpreted: {
runId: "run_demo",
status: "completed",
frames: [
{ nodeId: "list_documents", stepType: "node", outcome: "ok", resolvedInput: {}, output: {}, stateChanges: {} },
{ nodeId: "review_issues", stepType: "interrupt", outcome: "submitted", resolvedInput: {}, output: {}, stateChanges: {} },
{ nodeId: "finalise_report", stepType: "node", outcome: "completed", resolvedInput: {}, output: {}, stateChanges: {} },
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run trace run_demo --from 0 --limit 50",
durationMs: 12,
};
describe("executeLiveDemoStep", () => {
it("executes exactly one deployment check", async () => {
mockedCallOperation.mockResolvedValueOnce({
ok: true,
operation: "workflow.deployments.inspect" as const,
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 4,
});
const result = await executeLiveDemoStep(
"http://127.0.0.1:8765/rpc",
initialLiveDemoContext,
);
expect(mockedCallOperation).toHaveBeenCalledOnce();
expect(result.events[0]?.stage).toBe("deployment_check");
expect(result.context.nextStage).toBe("run_start");
});
it("stops at issue_review after run start", async () => {
mockedCallOperation.mockResolvedValueOnce(interruptedStartResult);
const result = await executeLiveDemoStep(
"http://127.0.0.1:8765/rpc",
{ ...initialLiveDemoContext, nextStage: "run_start" },
);
expect(result.events.map((event) => event.stage)).toEqual([
"run_start",
"interrupt",
]);
expect(result.context.nextStage).toBe("run_resume");
expect(result.context.runId).toBe("run_demo");
});
it("does not retry failed mutations", async () => {
mockedCallOperation.mockResolvedValueOnce({
ok: false,
error: { code: "rpc_remote_error", message: "resume failed" },
exchange: { request: {}, response: {} },
});
const result = await executeLiveDemoStep(
"http://127.0.0.1:8765/rpc",
{ ...initialLiveDemoContext, nextStage: "run_resume", runId: "run_demo" },
{ approved: true, selectedIssueIds: ["risk-1"], comment: "Create it" },
);
expect(mockedCallOperation).toHaveBeenCalledOnce();
expect(result.events.at(-1)?.stage).toBe("failed");
});
it("resumes and returns run_resume event", async () => {
mockedCallOperation.mockResolvedValueOnce(completedResumeResult);
const result = await executeLiveDemoStep(
"http://127.0.0.1:8765/rpc",
{ ...initialLiveDemoContext, nextStage: "run_resume", runId: "run_demo" },
{ approved: true, selectedIssueIds: ["risk-1"], comment: "Create it" },
);
expect(result.events[0]?.stage).toBe("run_resume");
expect(result.context.nextStage).toBe("trace_read");
expect(result.context.output).not.toBeNull();
});
it("reads trace and emits completed event", async () => {
mockedCallOperation.mockResolvedValueOnce(traceResult);
const result = await executeLiveDemoStep(
"http://127.0.0.1:8765/rpc",
{
...initialLiveDemoContext,
nextStage: "trace_read",
runId: "run_demo",
output: {
approved: true,
markdown: "# Report",
created_issues: [],
selected_issue_ids: [],
comment: null,
},
},
);
expect(result.events.map((event) => event.stage)).toEqual([
"trace_read",
"completed",
]);
expect(result.context.nextStage).toBe("done");
});
it("returns empty events when done", async () => {
const result = await executeLiveDemoStep(
"http://127.0.0.1:8765/rpc",
{ ...initialLiveDemoContext, nextStage: "done" },
);
expect(result.events).toEqual([]);
expect(result.context.nextStage).toBe("done");
});
});
+295
View File
@@ -0,0 +1,295 @@
import { callOperation } from "../../connection/api.js";
import type { RpcResponse } from "../../connection/contracts.js";
import { decodeRunDetail, decodeTracePage, type TracePage } from "../../lifecycle/models.js";
import {
LDA_REPORT_DEPLOYMENT_ID,
LDA_REPORT_INTERRUPT_KIND,
ldaReportDemoInput,
} from "../ldaReportDemoConfig.js";
import {
parseLdaReportInterruptPayload,
parseLdaReportOutput,
type LdaReportInterruptPayload,
type LdaReportOutput,
} from "../ldaReportDemoModels.js";
import type { DemoEvent, DemoEventStage } from "./models.js";
export type LiveDemoStage =
| "deployment_check"
| "run_start"
| "run_resume"
| "trace_read"
| "done";
export type LiveDemoContext = {
readonly nextStage: LiveDemoStage;
readonly nextSequence: number;
readonly runId: string | null;
readonly interruptPayload: LdaReportInterruptPayload | null;
readonly output: LdaReportOutput | null;
readonly trace: TracePage | null;
};
export const initialLiveDemoContext: LiveDemoContext = {
nextStage: "deployment_check",
nextSequence: 0,
runId: null,
interruptPayload: null,
output: null,
trace: null,
};
export type DemoApproval = {
readonly approved: boolean;
readonly selectedIssueIds: ReadonlyArray<string>;
readonly comment: string;
readonly outcome?: "submitted" | "cancelled";
};
export type LiveStepResult = {
readonly context: LiveDemoContext;
readonly events: ReadonlyArray<DemoEvent>;
};
const eventFromResult = (
context: LiveDemoContext,
stage: DemoEventStage,
operation: string,
reason: string,
params: unknown,
result: RpcResponse,
runId: string | null,
): DemoEvent => ({
id: `live-${context.nextSequence}-${stage}-${runId ?? "pending"}`,
sequence: context.nextSequence,
stage,
operation,
reason: result.ok ? reason : result.error.message,
equivalentCli: result.ok ? result.equivalentCli : null,
params,
rawResponse: result.exchange.response,
interpreted: result.ok ? result.interpreted : null,
durationMs: result.ok ? result.durationMs : 0,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId,
},
recordedAt: new Date().toISOString(),
});
const syntheticEvent = (
context: LiveDemoContext,
stage: "interrupt" | "completed" | "failed",
reason: string,
interpreted: unknown,
runId: string | null,
sequenceOffset = 0,
): DemoEvent => ({
id: `live-${context.nextSequence + sequenceOffset}-${stage}-${runId ?? "pending"}`,
sequence: context.nextSequence + sequenceOffset,
stage,
operation: null,
reason,
equivalentCli: null,
params: {},
rawResponse: null,
interpreted,
durationMs: 0,
resultingIds: { deploymentId: LDA_REPORT_DEPLOYMENT_ID, runId },
recordedAt: new Date().toISOString(),
});
const operationForStage = (stage: LiveDemoStage): string | null => {
switch (stage) {
case "deployment_check":
return "workflow.deployments.inspect";
case "run_start":
return "workflow.runs.start";
case "run_resume":
return "workflow.runs.resume";
case "trace_read":
return "workflow.runs.trace";
case "done":
return null;
}
};
export const failedLiveDemoEvent = (
context: LiveDemoContext,
reason: string,
): DemoEvent => ({
id: `live-${context.nextSequence}-failed-${context.runId ?? "pending"}`,
sequence: context.nextSequence,
stage: "failed",
operation: operationForStage(context.nextStage),
reason,
equivalentCli: null,
params: {},
rawResponse: null,
interpreted: null,
durationMs: 0,
resultingIds: {
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
runId: context.runId,
},
recordedAt: new Date().toISOString(),
});
export const executeLiveDemoStep = async (
target: string,
context: LiveDemoContext,
approval?: DemoApproval,
): Promise<LiveStepResult> => {
switch (context.nextStage) {
case "deployment_check": {
const params = { deployment_id: LDA_REPORT_DEPLOYMENT_ID };
const result = await callOperation("workflow.deployments.inspect", target, params);
const event = eventFromResult(
context,
result.ok ? "deployment_check" : "failed",
"workflow.deployments.inspect",
"Confirm the prepared report deployment exists.",
params,
result,
null,
);
return {
events: [event],
context: result.ok
? { ...context, nextStage: "run_start", nextSequence: context.nextSequence + 1 }
: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
};
}
case "run_start": {
const params = {
deployment_id: LDA_REPORT_DEPLOYMENT_ID,
workflow_input: ldaReportDemoInput,
trace_range: { start: 0, limit: 50 },
};
const result = await callOperation("workflow.runs.start", target, params);
if (!result.ok) {
return {
events: [eventFromResult(context, "failed", "workflow.runs.start", "Start the prepared run.", params, result, null)],
context: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
};
}
const detail = decodeRunDetail(result.interpreted);
if (detail.status !== "interrupted" || detail.interrupt?.kind !== LDA_REPORT_INTERRUPT_KIND) {
const failed = syntheticEvent(
context,
"failed",
"Demo run did not stop at issue_review interrupt.",
result.interpreted,
detail.runId,
);
return {
events: [failed],
context: { ...context, nextStage: "done", runId: detail.runId, nextSequence: context.nextSequence + 1 },
};
}
const payload = parseLdaReportInterruptPayload(detail.interrupt.payload);
const startEvent = eventFromResult(
context,
"run_start",
"workflow.runs.start",
"Start the prepared report workflow.",
params,
result,
detail.runId,
);
const interruptEvent = syntheticEvent(
context,
"interrupt",
"Pause for typed issue review.",
{ payload, outcomes: detail.interrupt.outcomes },
detail.runId,
1,
);
return {
events: [startEvent, interruptEvent],
context: {
...context,
nextStage: "run_resume",
runId: detail.runId,
interruptPayload: payload,
nextSequence: context.nextSequence + 2,
},
};
}
case "run_resume": {
if (!context.runId || !approval) {
throw new Error("run_resume requires a run id and explicit approval");
}
const params = {
run_id: context.runId,
resume_payload: {
approved: approval.approved,
selected_issue_ids: [...approval.selectedIssueIds],
comment: approval.comment,
},
resume_outcome: approval.outcome ?? (approval.approved ? "submitted" : "cancelled"),
trace_range: { start: 0, limit: 50 },
};
const result = await callOperation("workflow.runs.resume", target, params);
if (!result.ok) {
return {
events: [eventFromResult(context, "failed", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
context: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
};
}
const detail = decodeRunDetail(result.interpreted);
const output = parseLdaReportOutput(detail.output);
return {
events: [eventFromResult(context, "run_resume", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
context: {
...context,
nextStage: "trace_read",
output,
nextSequence: context.nextSequence + 1,
},
};
}
case "trace_read": {
if (!context.runId || !context.output) {
throw new Error("trace_read requires a completed run and output");
}
const params = { run_id: context.runId, trace_range: { start: 0, limit: 50 } };
const result = await callOperation("workflow.runs.trace", target, params);
if (!result.ok) {
return {
events: [eventFromResult(context, "failed", "workflow.runs.trace", "Read the final run trace.", params, result, context.runId)],
context: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
};
}
const trace = decodeTracePage(result.interpreted);
const traceEvent = eventFromResult(
context,
"trace_read",
"workflow.runs.trace",
"Read the final run trace.",
params,
result,
context.runId,
);
const completedEvent = syntheticEvent(
context,
"completed",
"The prepared report demo completed.",
{ output: context.output, trace },
context.runId,
1,
);
return {
events: [traceEvent, completedEvent],
context: {
...context,
nextStage: "done",
trace,
nextSequence: context.nextSequence + 2,
},
};
}
case "done":
return { context, events: [] };
}
};
@@ -0,0 +1,55 @@
import * as v from "valibot";
export const DemoEventStageSchema = v.picklist([
"deployment_check",
"run_start",
"interrupt",
"run_resume",
"trace_read",
"completed",
"failed",
]);
const ResultingIdsSchema = v.object({
deploymentId: v.nullable(v.string()),
runId: v.nullable(v.string()),
});
export const DemoEventSchema = v.object({
id: v.string(),
sequence: v.pipe(v.number(), v.integer(), v.minValue(0)),
stage: DemoEventStageSchema,
operation: v.nullable(v.string()),
reason: v.string(),
equivalentCli: v.nullable(v.string()),
params: v.unknown(),
rawResponse: v.unknown(),
interpreted: v.unknown(),
durationMs: v.pipe(v.number(), v.minValue(0)),
resultingIds: ResultingIdsSchema,
recordedAt: v.string(),
});
export const DemoRecordingSchema = v.object({
schemaVersion: v.literal(1),
recordingId: v.string(),
title: v.string(),
createdAt: v.string(),
deploymentId: v.literal("lda_report_case_study.default"),
source: v.literal("reviewed_live_capture"),
events: v.array(DemoEventSchema),
});
export type DemoEventStage = v.InferOutput<typeof DemoEventStageSchema>;
export type DemoEvent = v.InferOutput<typeof DemoEventSchema>;
export type DemoRecording = v.InferOutput<typeof DemoRecordingSchema>;
export const decodeDemoRecording = (value: unknown): DemoRecording => {
const recording = v.parse(DemoRecordingSchema, value);
recording.events.forEach((event, index) => {
if (event.sequence !== index) {
throw new Error(`recording event sequence ${event.sequence} does not match index ${index}`);
}
});
return recording;
};
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import {
demoTimelineReducer,
initialDemoTimelineState,
currentDemoEvent,
} from "./reducer.js";
import type { DemoEvent } from "./models.js";
const event = (sequence: number, stage: DemoEvent["stage"]): DemoEvent => ({
id: `event-${sequence}`,
sequence,
stage,
operation: stage === "interrupt" || stage === "completed" ? null : "workflow.runs.start",
reason: `Apply ${stage}`,
equivalentCli: null,
params: {},
rawResponse: {},
interpreted: {},
durationMs: 1,
resultingIds: {
deploymentId: "lda_report_case_study.default",
runId: sequence > 0 ? "run_demo" : null,
},
recordedAt: "2026-07-03T00:00:00.000Z",
});
describe("demoTimelineReducer", () => {
it("starts live playback in running phase", () => {
const state = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "live",
events: [],
});
expect(state.phase).toBe("running");
expect(state.autoplay).toBe(true);
expect(state.appliedCount).toBe(0);
});
it("applies events in order", () => {
const started = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "replay",
events: [event(0, "deployment_check"), event(1, "run_start")],
});
const applied = demoTimelineReducer(started, { type: "apply_next" });
expect(applied.appliedCount).toBe(1);
expect(currentDemoEvent(applied)?.stage).toBe("deployment_check");
});
it("always pauses at an interrupt", () => {
const started = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "replay",
events: [event(0, "interrupt")],
});
const applied = demoTimelineReducer(started, { type: "apply_next" });
expect(applied.phase).toBe("review");
expect(applied.autoplay).toBe(false);
});
it("stops at completion and failure", () => {
for (const stage of ["completed", "failed"] as const) {
const started = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "replay",
events: [event(0, stage)],
});
const applied = demoTimelineReducer(started, { type: "apply_next" });
expect(applied.phase).toBe(stage);
expect(applied.autoplay).toBe(false);
}
});
it("pause and play preserve playback position", () => {
const started = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "replay",
events: [event(0, "deployment_check")],
});
const paused = demoTimelineReducer(started, { type: "pause" });
const resumed = demoTimelineReducer(paused, { type: "play" });
expect(paused.phase).toBe("paused");
expect(resumed.phase).toBe("running");
expect(resumed.appliedCount).toBe(0);
});
it("keeps manual playback paused after applying one ordinary event", () => {
const started = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "replay",
events: [event(0, "deployment_check")],
});
const paused = demoTimelineReducer(started, { type: "pause" });
const applied = demoTimelineReducer(paused, { type: "apply_next" });
expect(applied.phase).toBe("paused");
expect(applied.appliedCount).toBe(1);
expect(applied.autoplay).toBe(false);
});
it("restart clears transient playback without deleting mode", () => {
const started = demoTimelineReducer(initialDemoTimelineState, {
type: "start",
mode: "replay",
events: [event(0, "completed")],
});
const restarted = demoTimelineReducer(started, { type: "restart" });
expect(restarted.phase).toBe("ready");
expect(restarted.mode).toBe("replay");
expect(restarted.appliedCount).toBe(0);
});
});
@@ -0,0 +1,115 @@
import type { DemoEvent } from "./models.js";
export type DemoMode = "live" | "replay";
export type DemoTimelinePhase =
| "ready"
| "running"
| "paused"
| "review"
| "completed"
| "failed";
export type DemoTimelineState = {
readonly mode: DemoMode;
readonly phase: DemoTimelinePhase;
readonly events: ReadonlyArray<DemoEvent>;
readonly appliedCount: number;
readonly autoplay: boolean;
readonly error: string | null;
};
export const initialDemoTimelineState: DemoTimelineState = {
mode: "live",
phase: "ready",
events: [],
appliedCount: 0,
autoplay: false,
error: null,
};
export type DemoTimelineAction =
| { readonly type: "set_mode"; readonly mode: DemoMode }
| { readonly type: "start"; readonly mode: DemoMode; readonly events: ReadonlyArray<DemoEvent> }
| { readonly type: "append_live_event"; readonly event: DemoEvent }
| { readonly type: "apply_next" }
| { readonly type: "pause" }
| { readonly type: "play" }
| { readonly type: "continue_review" }
| { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent }
| { readonly type: "restart" };
const phaseAfterEvent = (event: DemoEvent): DemoTimelinePhase => {
if (event.stage === "interrupt") return "review";
if (event.stage === "completed") return "completed";
if (event.stage === "failed") return "failed";
return "running";
};
const phaseAfterApply = (
state: DemoTimelineState,
event: DemoEvent,
): DemoTimelinePhase => {
const eventPhase = phaseAfterEvent(event);
if (eventPhase !== "running") return eventPhase;
return state.autoplay ? "running" : "paused";
};
export const demoTimelineReducer = (
state: DemoTimelineState,
action: DemoTimelineAction,
): DemoTimelineState => {
switch (action.type) {
case "set_mode":
return { ...initialDemoTimelineState, mode: action.mode };
case "start":
return {
mode: action.mode,
phase: "running",
events: action.events,
appliedCount: 0,
autoplay: true,
error: null,
};
case "append_live_event":
return { ...state, events: [...state.events, action.event] };
case "apply_next": {
const event = state.events[state.appliedCount];
if (!event) return state;
const phase = phaseAfterApply(state, event);
return {
...state,
phase,
appliedCount: state.appliedCount + 1,
autoplay: phase === "running" ? state.autoplay : false,
error: phase === "failed" ? event.reason : null,
};
}
case "pause":
return state.phase === "running"
? { ...state, phase: "paused", autoplay: false }
: state;
case "play":
return state.phase === "paused"
? { ...state, phase: "running", autoplay: true }
: state;
case "continue_review":
return state.phase === "review"
? { ...state, phase: "running", autoplay: true }
: state;
case "fail":
return {
...state,
phase: "failed",
events: action.event ? [...state.events, action.event] : state.events,
autoplay: false,
error: action.message,
};
case "restart":
return { ...initialDemoTimelineState, mode: state.mode };
default:
return state;
}
};
export const currentDemoEvent = (state: DemoTimelineState): DemoEvent | null =>
state.appliedCount > 0 ? state.events[state.appliedCount - 1] ?? null : null;
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording, nextReplayEvent } from "./replay.js";
vi.mock("../../connection/api.js", () => ({
callOperation: vi.fn(() => {
throw new Error("replay must not call RPC");
}),
}));
describe("canonical demo recording", () => {
it("loads a complete reviewed recording", () => {
const recording = loadCanonicalDemoRecording();
expect(recording.schemaVersion).toBe(1);
expect(recording.deploymentId).toBe("lda_report_case_study.default");
expect(recording.events.map((event) => event.stage)).toEqual([
"deployment_check",
"run_start",
"interrupt",
"run_resume",
"trace_read",
"completed",
]);
});
it("returns one replay event by applied count", () => {
const recording = loadCanonicalDemoRecording();
expect(nextReplayEvent(recording, 0)?.stage).toBe("deployment_check");
expect(nextReplayEvent(recording, recording.events.length)).toBeNull();
});
});
@@ -0,0 +1,21 @@
import recordingText from "../recordings/lda-report-success.v1.json?raw";
import { decodeDemoRecording, type DemoEvent, type DemoRecording } from "./models.js";
export const loadCanonicalDemoRecording = (): DemoRecording => {
let parsed: unknown;
try {
parsed = JSON.parse(recordingText);
} catch (error) {
throw new Error(
`canonical demo recording is not valid JSON: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
return decodeDemoRecording(parsed);
};
export const nextReplayEvent = (
recording: DemoRecording,
appliedCount: number,
): DemoEvent | null => recording.events[appliedCount] ?? null;
@@ -0,0 +1,379 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { callOperation } from "../connection/api.js";
import { useDemoTimeline } from "./useDemoTimeline.js";
vi.mock("../connection/api.js", () => ({ callOperation: vi.fn() }));
const mockedCallOperation = vi.mocked(callOperation);
afterEach(() => {
vi.useRealTimers();
cleanup();
});
beforeEach(() => {
mockedCallOperation.mockReset();
vi.useRealTimers();
});
describe("useDemoTimeline", () => {
it("starts in ready phase with live mode", () => {
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
expect(result.current.state.phase).toBe("ready");
expect(result.current.state.mode).toBe("live");
expect(result.current.output).toBeNull();
expect(result.current.trace).toBeNull();
});
it("replay advances without calling RPC", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
act(() => result.current.setMode("replay"));
act(() => result.current.start());
await act(async () => vi.advanceTimersByTimeAsync(900));
expect(mockedCallOperation).not.toHaveBeenCalled();
expect(result.current.state.appliedCount).toBeGreaterThan(0);
});
it("live Next executes exactly one operation", async () => {
mockedCallOperation.mockResolvedValueOnce({
ok: true,
operation: "workflow.deployments.inspect" as const,
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 4,
});
const { result } = renderHook(() =>
useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()),
);
act(() => result.current.start());
act(() => result.current.pause());
await act(async () => result.current.next());
expect(mockedCallOperation).toHaveBeenCalledOnce();
expect(result.current.state.events).toHaveLength(1);
expect(result.current.state.appliedCount).toBe(1);
expect(result.current.state.phase).toBe("paused");
});
it("exposes the in-flight lock while a live operation is pending", async () => {
let resolveOperation!: (value: Awaited<ReturnType<typeof callOperation>>) => void;
mockedCallOperation.mockImplementationOnce(
() => new Promise((resolve) => {
resolveOperation = resolve;
}),
);
const { result } = renderHook(() =>
useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()),
);
act(() => result.current.start());
act(() => result.current.pause());
let pending!: Promise<void>;
act(() => {
pending = result.current.next();
});
expect(result.current.inFlight).toBe(true);
resolveOperation({
ok: true,
operation: "workflow.deployments.inspect",
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 4,
});
await act(async () => pending);
expect(result.current.inFlight).toBe(false);
});
it("live autoplay stops at the issue review interrupt", async () => {
vi.useFakeTimers();
mockedCallOperation
.mockResolvedValueOnce({
ok: true,
operation: "workflow.deployments.inspect" as const,
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 4,
})
.mockResolvedValueOnce({
ok: true,
operation: "workflow.runs.start" as const,
label: "Start run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
interrupt: {
kind: "issue_review",
payload: {
report_markdown: "# Report",
proposed_issues: [
{ id: "risk-1", title: "Defense", body: "Review paths.", severity: "medium" },
],
},
outcomes: ["submitted", "cancelled"],
typed: true,
request_schema: { type: "object" },
resume_schema: { type: "object" },
},
outcome: null,
error: null,
output: null,
diagnostics: [],
traceCount: 6,
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: "wf.workflow.resume_run",
reason: "Run is interrupted for issue review.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 88,
});
const { result } = renderHook(() =>
useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()),
);
act(() => result.current.start());
await act(async () => vi.advanceTimersByTimeAsync(900));
await act(async () => vi.advanceTimersByTimeAsync(900));
expect(result.current.state.phase).toBe("review");
expect(result.current.state.events.map((event) => event.stage)).toEqual([
"deployment_check",
"run_start",
"interrupt",
]);
expect(mockedCallOperation).toHaveBeenCalledTimes(2);
});
it("live review submission advances through resume and direct trace response", async () => {
vi.useFakeTimers();
mockedCallOperation
.mockResolvedValueOnce({
ok: true,
operation: "workflow.deployments.inspect" as const,
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 4,
})
.mockResolvedValueOnce({
ok: true,
operation: "workflow.runs.start" as const,
label: "Start run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
interrupt: {
kind: "issue_review",
payload: {
report_markdown: "# Report",
proposed_issues: [
{ id: "risk-1", title: "Defense", body: "Review paths.", severity: "medium" },
],
},
outcomes: ["submitted", "cancelled"],
typed: true,
request_schema: { type: "object" },
resume_schema: { type: "object" },
},
outcome: null,
error: null,
output: null,
diagnostics: [],
traceCount: 6,
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: "wf.workflow.resume_run",
reason: "Run is interrupted for issue review.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 88,
})
.mockResolvedValueOnce({
ok: true,
operation: "workflow.runs.resume" as const,
label: "Resume run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "completed",
error: null,
output: {
approved: true,
markdown: "# Report",
created_issues: [{ id: "ISSUE-001", title: "Defense", url: "local://issues/ISSUE-001" }],
selected_issue_ids: ["risk-1"],
comment: "Create it.",
},
diagnostics: [],
traceCount: 10,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
durationMs: 63,
})
.mockResolvedValueOnce({
ok: true,
operation: "workflow.runs.trace" as const,
label: "Read trace",
interpreted: {
runId: "run_demo",
status: "completed",
frames: [
{ nodeId: "list_documents", stepType: "node", outcome: "ok", resolvedInput: {}, output: {}, stateChanges: {} },
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run trace run_demo --from 0 --limit 50",
durationMs: 12,
});
const { result } = renderHook(() =>
useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()),
);
act(() => result.current.start());
await act(async () => vi.advanceTimersByTimeAsync(900));
await act(async () => vi.advanceTimersByTimeAsync(900));
expect(result.current.state.phase).toBe("review");
await act(async () => result.current.submitSelectedIssues(["risk-1"], "Create it."));
await act(async () => vi.advanceTimersByTimeAsync(900));
await act(async () => vi.advanceTimersByTimeAsync(900));
expect(result.current.state.phase).toBe("completed");
expect(result.current.trace?.frames).toHaveLength(1);
});
it("shows a failed phase when a live operation rejects", async () => {
mockedCallOperation.mockRejectedValueOnce(new Error("transport exploded"));
const { result } = renderHook(() =>
useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()),
);
act(() => result.current.start());
act(() => result.current.pause());
await act(async () => result.current.next());
expect(result.current.state.phase).toBe("failed");
expect(result.current.state.error).toBe("transport exploded");
expect(result.current.state.events.at(-1)?.stage).toBe("failed");
expect(result.current.state.events.at(-1)?.operation).toBe("workflow.deployments.inspect");
});
it("restart preserves mode and clears transient content", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
act(() => result.current.setMode("replay"));
act(() => result.current.start());
await act(async () => vi.advanceTimersByTimeAsync(900));
act(() => result.current.restart());
expect(result.current.state.phase).toBe("ready");
expect(result.current.state.mode).toBe("replay");
expect(result.current.output).toBeNull();
});
it("replay stops at review phase", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
act(() => result.current.setMode("replay"));
act(() => result.current.start());
// Advance through deployment_check (0), run_start (1), and interrupt (2)
for (let i = 0; i < 3; i++) {
await act(async () => vi.advanceTimersByTimeAsync(900));
}
expect(result.current.state.phase).toBe("review");
expect(result.current.interruptPayload).not.toBeNull();
});
it("replay continue advances through the recorded submitted branch", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
act(() => result.current.setMode("replay"));
act(() => result.current.start());
for (let i = 0; i < 3; i++) {
await act(async () => vi.advanceTimersByTimeAsync(900));
}
expect(result.current.state.phase).toBe("review");
await act(async () => result.current.submitSelectedIssues(["risk-1"], "Create it."));
for (let i = 0; i < 3; i++) {
await act(async () => vi.advanceTimersByTimeAsync(900));
}
expect(result.current.state.phase).toBe("completed");
expect(result.current.output?.created_issues).toHaveLength(1);
expect(result.current.trace?.frames.length).toBeGreaterThan(0);
});
it("missingDeploymentMessage shows when live mode with null target", () => {
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
expect(result.current.missingDeploymentMessage).toContain("Not connected");
});
});
@@ -0,0 +1,257 @@
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import type { EvidenceRecord } from "../app/state.js";
import { decodeRunDetail, decodeTracePage, type TracePage } from "../lifecycle/models.js";
import {
parseLdaReportInterruptPayload,
parseLdaReportOutput,
type LdaReportInterruptPayload,
type LdaReportOutput,
} from "./ldaReportDemoModels.js";
import {
demoTimelineReducer,
initialDemoTimelineState,
type DemoMode,
type DemoTimelineState,
} from "./timeline/reducer.js";
import {
executeLiveDemoStep,
failedLiveDemoEvent,
initialLiveDemoContext,
type DemoApproval,
type LiveDemoContext,
} from "./timeline/live.js";
import { loadCanonicalDemoRecording } from "./timeline/replay.js";
type EvidenceRecorder = (record: EvidenceRecord) => void;
export type DemoTimelineController = {
readonly state: DemoTimelineState;
readonly inFlight: boolean;
readonly interruptPayload: LdaReportInterruptPayload | null;
readonly output: LdaReportOutput | null;
readonly trace: TracePage | null;
readonly missingDeploymentMessage: string | null;
readonly recordingId: string | null;
readonly canStart: boolean;
readonly setMode: (mode: DemoMode) => void;
readonly start: () => void;
readonly pause: () => void;
readonly play: () => void;
readonly next: () => Promise<void>;
readonly submitSelectedIssues: (
selectedIssueIds: ReadonlyArray<string>,
comment: string,
) => Promise<void>;
readonly cancelReview: (comment: string) => Promise<void>;
readonly restart: () => void;
};
const deriveRecordingId = (state: DemoTimelineState): string | null =>
state.mode === "replay" ? "lda-report-success-v1" : null;
const deriveMissingMessage = (mode: DemoMode, target: string | null): string | null => {
if (mode !== "live" || target !== null) return null;
return "Not connected. Select Live and connect to a workflow server, or switch to Replay.";
};
export const useDemoTimeline = (
target: string | null,
recordEvidence: EvidenceRecorder,
): DemoTimelineController => {
const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState);
const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext);
const recordEvidenceRef = useRef(recordEvidence);
recordEvidenceRef.current = recordEvidence;
const inFlightRef = useRef(false);
const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef(loadCanonicalDemoRecording());
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
const [output, setOutput] = useState<LdaReportOutput | null>(null);
const [trace, setTrace] = useState<TracePage | null>(null);
const step = useCallback(async () => {
if (inFlightRef.current) return;
if (state.appliedCount >= state.events.length && state.mode === "replay") return;
inFlightRef.current = true;
setInFlight(true);
try {
if (state.mode === "replay") {
const event = state.events[state.appliedCount];
if (!event) return;
dispatch({ type: "apply_next" });
if (event.stage === "interrupt" && event.interpreted) {
const interpreted = event.interpreted as { payload: LdaReportInterruptPayload };
setInterruptPayload(interpreted.payload);
}
if (event.stage === "run_resume" && event.interpreted) {
const interpreted = event.interpreted as { output: LdaReportOutput };
setOutput(parseLdaReportOutput(interpreted.output));
}
if (event.stage === "trace_read" && event.interpreted) {
setTrace(decodeTracePage(event.interpreted));
}
if (event.stage === "completed" && event.interpreted) {
const interpreted = event.interpreted as { output: LdaReportOutput; trace: TracePage };
setOutput(parseLdaReportOutput(interpreted.output));
setTrace(decodeTracePage(interpreted.trace));
}
} else {
if (!target) return;
const approval = approvalRef.current;
approvalRef.current = null;
const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined);
liveContextRef.current = result.context;
for (const event of result.events) {
dispatch({ type: "append_live_event", event });
dispatch({ type: "apply_next" });
if (event.stage === "interrupt" && event.interpreted) {
const interpreted = event.interpreted as { payload: LdaReportInterruptPayload };
setInterruptPayload(interpreted.payload);
}
if (event.stage === "run_resume" && event.operation) {
recordEvidenceRef.current({
id: event.id,
operation: event.operation,
label: "Resume run",
equivalentCli: event.equivalentCli ?? "",
request: event.params,
response: event.rawResponse,
durationMs: event.durationMs,
});
}
if (event.stage === "run_resume" && event.interpreted) {
const interpreted = event.interpreted as { output: LdaReportOutput };
setOutput(parseLdaReportOutput(interpreted.output));
}
if (event.stage === "trace_read" && event.interpreted) {
setTrace(decodeTracePage(event.interpreted));
}
if (event.stage === "trace_read" && event.operation) {
recordEvidenceRef.current({
id: event.id,
operation: event.operation,
label: "Read trace",
equivalentCli: event.equivalentCli ?? "",
request: event.params,
response: event.rawResponse,
durationMs: event.durationMs,
});
}
if (event.operation && event.stage !== "run_resume" && event.stage !== "trace_read") {
recordEvidenceRef.current({
id: event.id,
operation: event.operation,
label: event.reason,
equivalentCli: event.equivalentCli ?? "",
request: event.params,
response: event.rawResponse,
durationMs: event.durationMs,
});
}
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (state.mode === "live") {
const event = failedLiveDemoEvent(liveContextRef.current, message);
dispatch({ type: "append_live_event", event });
dispatch({ type: "apply_next" });
} else {
dispatch({ type: "fail", message });
}
} finally {
inFlightRef.current = false;
setInFlight(false);
}
}, [state.mode, state.appliedCount, state.events, target]);
// Autoplay timer
useEffect(() => {
if (state.phase !== "running" || !state.autoplay || inFlightRef.current) return;
const timer = setTimeout(() => {
void step();
}, 900);
return () => clearTimeout(timer);
}, [state.phase, state.autoplay, state.appliedCount, step]);
const setMode = useCallback((mode: DemoMode) => {
dispatch({ type: "set_mode", mode });
liveContextRef.current = initialLiveDemoContext;
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
const start = useCallback(() => {
if (state.mode === "replay") {
const recording = activeRecording.current;
dispatch({ type: "start", mode: "replay", events: recording.events });
} else {
liveContextRef.current = initialLiveDemoContext;
dispatch({ type: "start", mode: "live", events: [] });
}
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, [state.mode]);
const pause = useCallback(() => dispatch({ type: "pause" }), []);
const play = useCallback(() => dispatch({ type: "play" }), []);
const next = useCallback(async () => {
dispatch({ type: "pause" });
await step();
}, [step]);
const submitSelectedIssues = useCallback(async (
selectedIssueIds: ReadonlyArray<string>,
comment: string,
) => {
approvalRef.current = {
approved: true,
selectedIssueIds,
comment,
outcome: "submitted",
};
dispatch({ type: "continue_review" });
}, []);
const cancelReview = useCallback(async (comment: string) => {
approvalRef.current = {
approved: false,
selectedIssueIds: [],
comment,
outcome: "cancelled",
};
dispatch({ type: "continue_review" });
}, []);
const restart = useCallback(() => {
dispatch({ type: "restart" });
liveContextRef.current = initialLiveDemoContext;
setInterruptPayload(null);
setOutput(null);
setTrace(null);
}, []);
return {
state,
inFlight,
interruptPayload,
output,
trace,
missingDeploymentMessage: deriveMissingMessage(state.mode, target),
recordingId: deriveRecordingId(state),
canStart: state.mode === "replay" || target !== null,
setMode,
start,
pause,
play,
next,
submitSelectedIssues,
cancelReview,
restart,
};
};
@@ -1,204 +0,0 @@
import { renderHook, waitFor, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { useLdaReportDemo } from "./useLdaReportDemo.js";
import { callOperation } from "../connection/api.js";
vi.mock("../connection/api.js", () => ({
callOperation: vi.fn(),
}));
const mockedCallOperation = vi.mocked(callOperation);
beforeEach(() => {
mockedCallOperation.mockReset();
});
const inspectResult = {
ok: true as const,
operation: "workflow.deployments.inspect" as const,
label: "Inspect deployment",
interpreted: {
id: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
durationMs: 5,
};
const startResult = {
ok: true as const,
operation: "workflow.runs.start" as const,
label: "Start run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
interrupt: {
kind: "issue_review",
payload: {
report_markdown: "# lda.chat Thesis And Project Readiness Report",
proposed_issues: [
{
id: "demo-issue-1",
title: "Prepare demo script",
body: "Write the defense walkthrough.",
severity: "medium",
},
],
},
outcomes: ["submitted", "cancelled"],
},
outcome: null,
error: null,
output: null,
diagnostics: [],
traceCount: 1,
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: "wf.workflow.resume_run",
reason: "run is interrupted",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 10,
};
const resumeResult = {
ok: true as const,
operation: "workflow.runs.resume" as const,
label: "Resume run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "completed",
error: null,
output: {
approved: true,
markdown: "# lda.chat Thesis And Project Readiness Report",
created_issues: [
{
id: "ISSUE-001",
title: "Prepare demo script",
url: "local://issues/ISSUE-001",
},
],
selected_issue_ids: ["demo-issue-1"],
comment: "Create selected issues.",
},
diagnostics: [],
traceCount: 4,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
durationMs: 12,
};
const traceResult = {
ok: true as const,
operation: "workflow.runs.trace" as const,
label: "Read run trace",
interpreted: {
runId: "run_demo",
status: "completed",
frames: [
{
nodeId: "generate",
stepType: "tool",
outcome: "completed",
resolvedInput: {},
output: {},
stateChanges: {},
},
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run trace run_demo --from 0 --limit 50",
durationMs: 8,
};
describe("useLdaReportDemo", () => {
it("reports missing deployment without trying to create it", async () => {
mockedCallOperation.mockResolvedValue({
ok: false,
error: { code: "rpc_remote_error", message: "not found" },
exchange: { request: {}, response: {} },
});
const { result } = renderHook(() =>
useLdaReportDemo("http://127.0.0.1:8765/rpc", vi.fn()),
);
await waitFor(() => {
expect(result.current.state.phase).toBe("missing");
});
expect(mockedCallOperation).toHaveBeenCalledWith(
"workflow.deployments.inspect",
"http://127.0.0.1:8765/rpc",
{ deployment_id: "lda_report_case_study.default" },
);
expect(mockedCallOperation).not.toHaveBeenCalledWith(
"workflow.runs.start",
expect.anything(),
expect.anything(),
);
});
it("transitions through interrupted start and completed resume", async () => {
mockedCallOperation
.mockResolvedValueOnce(inspectResult)
.mockResolvedValueOnce(startResult)
.mockResolvedValueOnce(resumeResult)
.mockResolvedValueOnce(traceResult);
const { result } = renderHook(() =>
useLdaReportDemo("http://127.0.0.1:8765/rpc", vi.fn()),
);
await waitFor(() => {
expect(result.current.state.phase).toBe("ready");
});
await act(async () => {
await result.current.startRun();
});
expect(result.current.state.phase).toBe("interrupted");
expect(result.current.state.interruptPayload?.proposed_issues[0]?.id).toBe("demo-issue-1");
await act(async () => {
await result.current.submitSelectedIssues(["demo-issue-1"], "Create it.");
});
expect(result.current.state.phase).toBe("completed");
expect(result.current.state.output?.created_issues[0]?.id).toBe("ISSUE-001");
expect(result.current.state.trace?.frames.length).toBeGreaterThan(0);
});
});
@@ -1,224 +0,0 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { callOperation } from "../connection/api.js";
import type { EvidenceRecord } from "../app/state.js";
import {
LDA_REPORT_DEPLOYMENT_ID,
LDA_REPORT_INTERRUPT_KIND,
ldaReportDemoInput,
} from "./ldaReportDemoConfig.js";
import {
parseLdaReportInterruptPayload,
parseLdaReportOutput,
type LdaReportInterruptPayload,
type LdaReportOutput,
} from "./ldaReportDemoModels.js";
import { decodeRunDetail, decodeTracePage, type TracePage } from "../lifecycle/models.js";
type DemoPhase =
| "idle"
| "checking"
| "missing"
| "ready"
| "starting"
| "interrupted"
| "resuming"
| "completed"
| "error";
type DemoState = {
readonly phase: DemoPhase;
readonly message: string | null;
readonly runId: string | null;
readonly interruptPayload: LdaReportInterruptPayload | null;
readonly output: LdaReportOutput | null;
readonly trace: TracePage | null;
};
const initialState: DemoState = {
phase: "idle",
message: null,
runId: null,
interruptPayload: null,
output: null,
trace: null,
};
type DemoAction =
| { readonly type: "checking" }
| { readonly type: "missing"; readonly message: string }
| { readonly type: "ready" }
| { readonly type: "starting" }
| { readonly type: "interrupted"; readonly runId: string; readonly payload: LdaReportInterruptPayload }
| { readonly type: "resuming" }
| { readonly type: "completed"; readonly output: LdaReportOutput; readonly trace: TracePage | null }
| { readonly type: "error"; readonly message: string };
const reducer = (state: DemoState, action: DemoAction): DemoState => {
switch (action.type) {
case "checking":
return { ...initialState, phase: "checking" };
case "missing":
return { ...initialState, phase: "missing", message: action.message };
case "ready":
return { ...initialState, phase: "ready" };
case "starting":
return { ...state, phase: "starting", message: null };
case "interrupted":
return {
...state,
phase: "interrupted",
runId: action.runId,
interruptPayload: action.payload,
output: null,
trace: null,
};
case "resuming":
return { ...state, phase: "resuming", message: null };
case "completed":
return { ...state, phase: "completed", output: action.output, trace: action.trace };
case "error":
return { ...state, phase: "error", message: action.message };
default:
return state;
}
};
type EvidenceRecorder = (record: EvidenceRecord) => void;
const recordOperationEvidence = (
recordEvidence: EvidenceRecorder,
result: Awaited<ReturnType<typeof callOperation>>,
) => {
if (!result.ok) return;
recordEvidence({
id: `demo-${result.operation}-${Date.now()}`,
operation: result.operation,
label: result.label,
equivalentCli: result.equivalentCli,
request: result.exchange.request,
response: result.exchange.response,
durationMs: result.durationMs,
});
};
export const useLdaReportDemo = (
target: string | null,
recordEvidence: EvidenceRecorder,
) => {
const [state, dispatch] = useReducer(reducer, initialState);
const recordEvidenceRef = useRef(recordEvidence);
recordEvidenceRef.current = recordEvidence;
const refresh = useCallback(async () => {
if (!target) return;
dispatch({ type: "checking" });
try {
const result = await callOperation(
"workflow.deployments.inspect",
target,
{ deployment_id: LDA_REPORT_DEPLOYMENT_ID },
);
recordOperationEvidence(recordEvidenceRef.current, result);
if (!result.ok) {
dispatch({ type: "missing", message: result.error.message });
return;
}
dispatch({ type: "ready" });
} catch (e: unknown) {
dispatch({ type: "missing", message: e instanceof Error ? e.message : "unknown error" });
}
}, [target]);
useEffect(() => {
void refresh();
}, [refresh]);
const startRun = useCallback(async () => {
if (!target) return;
dispatch({ type: "starting" });
try {
const result = await callOperation(
"workflow.runs.start",
target,
{
deployment_id: LDA_REPORT_DEPLOYMENT_ID,
workflow_input: ldaReportDemoInput,
trace_range: { start: 0, limit: 50 },
},
);
recordOperationEvidence(recordEvidenceRef.current, result);
if (!result.ok) {
dispatch({ type: "error", message: result.error.message });
return;
}
const detail = decodeRunDetail(result.interpreted);
if (detail.status !== "interrupted" || detail.interrupt?.kind !== LDA_REPORT_INTERRUPT_KIND) {
dispatch({ type: "error", message: "Demo run did not stop at issue_review interrupt." });
return;
}
dispatch({
type: "interrupted",
runId: detail.runId,
payload: parseLdaReportInterruptPayload(detail.interrupt.payload),
});
} catch (e: unknown) {
dispatch({ type: "error", message: e instanceof Error ? e.message : "unknown error" });
}
}, [target]);
const resume = useCallback(async (
resumePayload: { approved: boolean; selected_issue_ids: string[]; comment: string },
resumeOutcome: "submitted" | "cancelled",
) => {
if (!target || !state.runId) return;
dispatch({ type: "resuming" });
try {
const result = await callOperation(
"workflow.runs.resume",
target,
{
run_id: state.runId,
resume_payload: resumePayload,
resume_outcome: resumeOutcome,
trace_range: { start: 0, limit: 50 },
},
);
recordOperationEvidence(recordEvidenceRef.current, result);
if (!result.ok) {
dispatch({ type: "error", message: result.error.message });
return;
}
const detail = decodeRunDetail(result.interpreted);
const traceResult = await callOperation(
"workflow.runs.trace",
target,
{ run_id: detail.runId, trace_range: { start: 0, limit: 50 } },
);
recordOperationEvidence(recordEvidenceRef.current, traceResult);
const trace = traceResult.ok ? decodeTracePage(traceResult.interpreted) : null;
dispatch({
type: "completed",
output: parseLdaReportOutput(detail.output),
trace,
});
} catch (e: unknown) {
dispatch({ type: "error", message: e instanceof Error ? e.message : "unknown error" });
}
}, [target, state.runId]);
return {
state,
refresh,
startRun,
submitSelectedIssues: (selectedIssueIds: string[], comment: string) =>
resume(
{ approved: true, selected_issue_ids: selectedIssueIds, comment },
"submitted",
),
cancelReview: (comment: string) =>
resume(
{ approved: false, selected_issue_ids: [], comment },
"cancelled",
),
};
};
+47
View File
@@ -453,3 +453,50 @@ tbody tr:nth-child(10) { animation-delay: 270ms; }
flex-basis: auto; flex-basis: auto;
} }
} }
/* Demo timeline controls */
.demo-timeline-controls,
.demo-mode-switch {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.demo-replay-label {
font-family: var(--font-mono);
font-weight: 600;
}
.demo-replay-note {
font-style: italic;
opacity: 0.7;
}
/* Demo timeline list */
.demo-timeline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.5rem;
padding: 0;
list-style: none;
}
.demo-timeline li {
padding: 0.6rem;
border: 1px solid var(--color-border);
background: #fff;
}
.demo-timeline li[data-status="current"] {
border-color: var(--color-signal-green);
}
.demo-timeline li[data-status="pending"] {
opacity: 0.55;
}
.demo-timeline small {
display: block;
color: var(--color-slate);
}