feat: add demo timeline replay controls
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,88 +46,67 @@ 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: {
|
output: {
|
||||||
approved: true,
|
approved: true,
|
||||||
markdown: "# Report",
|
markdown: "# Report",
|
||||||
@@ -127,11 +123,6 @@ describe("LdaReportDemoPanel", () => {
|
|||||||
traceLimit: 50,
|
traceLimit: 50,
|
||||||
traceTruncated: false,
|
traceTruncated: false,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
refresh: vi.fn(),
|
|
||||||
startRun: vi.fn(),
|
|
||||||
submitSelectedIssues: vi.fn(),
|
|
||||||
cancelReview: vi.fn(),
|
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -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 · {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} · {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">
|
||||||
|
{state.mode === "replay" ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => controller.submitSelectedIssues(selectedIssueIds, comment)}
|
onClick={() => void controller.submitSelectedIssues(selectedIssueIds, comment)}
|
||||||
|
disabled={selectedIssueIds.length === 0}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => void controller.submitSelectedIssues(selectedIssueIds, comment)}
|
||||||
disabled={selectedIssueIds.length === 0}
|
disabled={selectedIssueIds.length === 0}
|
||||||
>
|
>
|
||||||
Resume and create selected issues
|
Resume and create selected issues
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => controller.cancelReview(comment)}>
|
)}
|
||||||
|
<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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user