54 KiB
Historical: This plan is completed. Kept for reference.
Demo Autoplay And Replay Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the direct lda-report demo controls with a deterministic Live/Replay timeline that supports Start, Play, Pause, Next, manual interrupt approval, Restart, and one committed reviewed recording.
Architecture: A pure reducer owns timeline state, while separate live and replay adapters produce the same validated DemoEvent envelope. useDemoTimeline is the only sequence owner and feeds the existing report, issue, trace, and raw-evidence views. Replay imports a schema-validated JSON recording as text and never contacts the workflow server.
Tech Stack: React 19, TypeScript 6, Valibot 1.4, Vitest 4, Testing Library, existing console callOperation() RPC bridge.
Global Constraints
- Live mode uses only public JSON-RPC operations through
callOperation(). - Replay mode performs zero RPC calls.
- The prepared deployment id remains
lda_report_case_study.default. - Autoplay never crosses or approves
issue_review. - Mutation operations are never retried automatically.
- Live and replay render through the same report, issue, trace, and evidence components.
- Replay is visibly labeled and must not imply that it created real issues.
- The committed recording contains no machine-specific paths, credentials, tokens, or unrelated store data.
- Playback position is an internal implementation detail; event
stagecarries meaning. - Do not add the demo agent, Astro presentation, backward live stepping, or arbitrary recording import in this slice.
File Structure
- Create
web/apps/console/src/demo/timeline/models.ts- Valibot schemas and TypeScript types for events and recordings.
- Create
web/apps/console/src/demo/timeline/reducer.ts- Pure playback state machine and selectors.
- Create
web/apps/console/src/demo/timeline/reducer.test.ts- State transition and invariant tests.
- Create
web/apps/console/src/demo/timeline/live.ts- One-step live RPC executor and event normalization.
- Create
web/apps/console/src/demo/timeline/live.test.ts- Live step tests, including no retry and interrupt stop.
- Create
web/apps/console/src/demo/timeline/replay.ts- Canonical recording loader and replay step helper.
- Create
web/apps/console/src/demo/timeline/replay.test.ts- Recording validation and zero-RPC replay tests.
- Create
web/apps/console/src/demo/recordings/lda-report-success.v1.json- Reviewed canonical success recording.
- Create
web/apps/console/src/demo/useDemoTimeline.ts- React controller for timers, live/replay stepping, approval, and reset.
- Create
web/apps/console/src/demo/useDemoTimeline.test.tsx- Hook tests with fake timers.
- Create
web/apps/console/src/demo/DemoTimelineControls.tsx- Mode and playback controls.
- Create
web/apps/console/src/demo/DemoTimeline.tsx- Compact event list with current-stage emphasis.
- Modify
web/apps/console/src/demo/LdaReportDemoPanel.tsx- Consume the timeline controller and reuse existing content views.
- Modify
web/apps/console/src/demo/LdaReportDemoPanel.test.tsx- Live/replay control and attribution tests.
- Modify
web/apps/console/src/app/App.tsx- Use
useDemoTimelineinstead ofuseLdaReportDemo.
- Use
- Modify
web/apps/console/src/app/App.test.tsx- Update controller mocks and mount assertions.
- Delete
web/apps/console/src/demo/useLdaReportDemo.ts - Delete
web/apps/console/src/demo/useLdaReportDemo.test.tsx- Prevent a second sequence owner from surviving as ghost compatibility.
- Modify
web/apps/console/src/styles/global.css- Timeline, controls, attribution, and current-event styles.
- Modify
web/README.md- Live/replay operator instructions.
- Modify
docs/current_roadmap.md- Mark the slice completed after verification.
- Move this plan to
docs/historical/superpowers/plans/after completion.
Task 1: Define And Validate Timeline Contracts
Files:
- Create:
web/apps/console/src/demo/timeline/models.ts - Create:
web/apps/console/src/demo/timeline/reducer.ts - Create:
web/apps/console/src/demo/timeline/reducer.test.ts
Interfaces:
-
Produces
DemoEvent,DemoRecording,DemoTimelineState,DemoTimelineAction,demoTimelineReducer,initialDemoTimelineState,currentDemoEvent, anddecodeDemoRecording. -
Later tasks must use these types rather than duplicate event or playback fields.
-
Step 1: Write reducer tests first
Create
web/apps/console/src/demo/timeline/reducer.test.ts: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("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); }); }); -
Step 2: Run the tests to verify RED
Run:
pnpm --dir web --filter @lda/console test -- src/demo/timeline/reducer.test.tsExpected: FAIL because timeline modules do not exist.
-
Step 3: Implement schemas and types
Create
web/apps/console/src/demo/timeline/models.ts: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; }; -
Step 4: Implement reducer
Create
web/apps/console/src/demo/timeline/reducer.ts: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"; }; 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 = phaseAfterEvent(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; -
Step 5: Run tests and typecheck
Run:
pnpm --dir web --filter @lda/console test -- src/demo/timeline/reducer.test.ts pnpm --dir web --filter @lda/console typecheckExpected: all pass.
-
Step 6: Commit
git add web/apps/console/src/demo/timeline/models.ts web/apps/console/src/demo/timeline/reducer.ts web/apps/console/src/demo/timeline/reducer.test.ts git commit -m "feat: define demo timeline state machine"
Task 2: Extract One-Step Live Execution
Files:
- Create:
web/apps/console/src/demo/timeline/live.ts - Create:
web/apps/console/src/demo/timeline/live.test.ts
Interfaces:
-
Consumes
callOperation(), demo config constants, and existing run/trace decoders. -
Produces
LiveDemoContext,initialLiveDemoContext, andexecuteLiveDemoStep(context, approval?). -
Each call executes at most one RPC operation and returns one or more normalized events plus the next context.
-
Step 1: Write live executor tests
Create
web/apps/console/src/demo/timeline/live.test.tswith mockedcallOperationand these cases: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()); describe("executeLiveDemoStep", () => { it("executes exactly one deployment check", async () => { mockedCallOperation.mockResolvedValueOnce({ 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, }); 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"); }); });Define
interruptedStartResultin the test with the same interpreted shape already used byuseLdaReportDemo.test.tsx. -
Step 2: Run tests to verify RED
Run:
pnpm --dir web --filter @lda/console test -- src/demo/timeline/live.test.tsExpected: FAIL because
live.tsdoes not exist. -
Step 3: Implement live context and event conversion
Create
web/apps/console/src/demo/timeline/live.tswith: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>; };Add private helpers:
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(), }); -
Step 4: Implement one-step execution
Implement
executeLiveDemoStepas an explicit switch. Each branch performs one RPC call, creates events, and advancesnextStage. Required behavior: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: [] }; } }; -
Step 5: Run live tests and typecheck
Run:
pnpm --dir web --filter @lda/console test -- src/demo/timeline/live.test.ts pnpm --dir web --filter @lda/console typecheckExpected: all pass.
-
Step 6: Commit
git add web/apps/console/src/demo/timeline/live.ts web/apps/console/src/demo/timeline/live.test.ts git commit -m "feat: execute live demo one operation at a time"
Task 3: Add Canonical Recording And Replay Loader
Files:
- Create:
web/apps/console/src/demo/recordings/lda-report-success.v1.json - Create:
web/apps/console/src/demo/timeline/replay.ts - Create:
web/apps/console/src/demo/timeline/replay.test.ts
Interfaces:
-
Produces
loadCanonicalDemoRecording()andnextReplayEvent(recording, appliedCount). -
Replay helpers contain no imports from
connection/api.ts. -
Step 1: Write replay tests
Create
web/apps/console/src/demo/timeline/replay.test.ts: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(); }); }); -
Step 2: Run tests to verify RED
Run:
pnpm --dir web --filter @lda/console test -- src/demo/timeline/replay.test.tsExpected: FAIL because recording and replay loader do not exist.
-
Step 3: Add reviewed recording
Create
web/apps/console/src/demo/recordings/lda-report-success.v1.jsonwith this sanitized fixture:{ "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" } ] } -
Step 4: Implement loader
Create
web/apps/console/src/demo/timeline/replay.ts: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; -
Step 5: Run replay tests and typecheck
Run:
pnpm --dir web --filter @lda/console test -- src/demo/timeline/replay.test.ts pnpm --dir web --filter @lda/console typecheckExpected: all pass.
-
Step 6: Commit
git add web/apps/console/src/demo/recordings/lda-report-success.v1.json web/apps/console/src/demo/timeline/replay.ts web/apps/console/src/demo/timeline/replay.test.ts git commit -m "feat: add reviewed lda report replay"
Task 4: Add The Timeline Controller
Files:
- Create:
web/apps/console/src/demo/useDemoTimeline.ts - Create:
web/apps/console/src/demo/useDemoTimeline.test.tsx - Delete:
web/apps/console/src/demo/useLdaReportDemo.ts - Delete:
web/apps/console/src/demo/useLdaReportDemo.test.tsx
Interfaces:
-
Produces
useDemoTimeline(target, recordEvidence). -
Controller methods:
setMode,start,pause,play,next,submitSelectedIssues,cancelReview, andrestart. -
Controller view data: timeline state, interrupt payload, output, trace, recording attribution, and missing-deployment message.
-
Step 1: Write controller tests with fake timers
Create
web/apps/console/src/demo/useDemoTimeline.test.tsxand cover: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); vi.useRealTimers(); }); it("live Next executes exactly one operation", async () => { 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(); }); it("autoplay stops at review", async () => { // Mock deployment inspect and interrupted run start. // Advance timers through both operations. expect(result.current.state.phase).toBe("review"); expect(result.current.state.autoplay).toBe(false); }); it("restart preserves mode and clears transient content", () => { act(() => result.current.restart()); expect(result.current.state.phase).toBe("ready"); expect(result.current.state.mode).toBe("replay"); expect(result.current.output).toBeNull(); });Use
afterEach(() => { vi.useRealTimers(); cleanup(); })so fake timers and rendered DOM cannot leak between tests. -
Step 2: Run tests to verify RED
Run:
pnpm --dir web --filter @lda/console test -- src/demo/useDemoTimeline.test.tsxExpected: FAIL because the hook does not exist.
-
Step 3: Implement controller
Implement
useDemoTimelinewith:useReducer(demoTimelineReducer, initialDemoTimelineState);useReffor live context, active approval, in-flight guard, andrecordEvidence;- a
step()callback that executes one live operation or applies one replay event; - one 900 ms autoplay timer in
useEffectonly while phase isrunning, autoplay is true, and no step is in flight; - event projection that updates
interruptPayload,output, andtracefrom eventinterpreteddata; - explicit review continuation that stores approval before resuming live or advances the reviewed branch in replay;
restart()that resets reducer and derived content without mutating the workflow store.
Required controller shape:
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 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; };When a live operation succeeds, forward its evidence to the existing
recordEvidencecallback. When it fails, preserve the failed event inside the timeline even if no global evidence record can be constructed. -
Step 4: Delete old sequence owner
Delete:
web/apps/console/src/demo/useLdaReportDemo.ts web/apps/console/src/demo/useLdaReportDemo.test.tsxSearch for stale imports:
rg -n 'useLdaReportDemo' web/apps/console/srcExpected after Task 5 integration: no matches.
-
Step 5: Run controller tests and typecheck
Run:
pnpm --dir web --filter @lda/console test -- src/demo/useDemoTimeline.test.tsx pnpm --dir web --filter @lda/console typecheckExpected: tests pass; typecheck may remain red only until Task 5 updates App and panel imports. If so, commit Tasks 4 and 5 together rather than committing a knowingly broken tree.
Task 5: Replace Demo Controls With Timeline UI
Files:
- Create:
web/apps/console/src/demo/DemoTimelineControls.tsx - Create:
web/apps/console/src/demo/DemoTimeline.tsx - Modify:
web/apps/console/src/demo/LdaReportDemoPanel.tsx - Modify:
web/apps/console/src/demo/LdaReportDemoPanel.test.tsx - Modify:
web/apps/console/src/app/App.tsx - Modify:
web/apps/console/src/app/App.test.tsx - Modify:
web/apps/console/src/styles/global.css
Interfaces:
-
Consumes
DemoTimelineControllerfrom Task 4. -
Produces one shared Live/Replay panel with visible attribution and deterministic controls.
-
Step 1: Write control/component tests
Add tests for:
expect(screen.getByRole("button", { name: "Live" })).toBeVisible(); expect(screen.getByRole("button", { name: "Replay" })).toBeVisible(); expect(screen.getByRole("button", { name: /start presentation/i })).toBeEnabled(); expect(screen.getByText(/recorded replay/i)).toBeVisible(); expect(screen.getByRole("button", { name: /continue/i })).toBeVisible(); expect(screen.getByText(/deployment check/i)).toBeVisible();Assert control availability by phase:
- ready: Start;
- running: Pause;
- paused: Play and Next;
- review: Continue/Cancel, no Play/Next;
- completed: Restart;
- failed: Reset/Restart.
-
Step 2: Implement
DemoTimelineControlsComponent props:
type DemoTimelineControlsProps = Pick< DemoTimelineController, "state" | "setMode" | "start" | "pause" | "play" | "next" | "restart" >;Mode buttons are disabled once playback leaves ready state.
Nextawaits the returned promise and is disabled while the controller reports an in-flight step. -
Step 3: Implement
DemoTimelineRender applied events plus the next pending event:
<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> -
Step 4: Update
LdaReportDemoPanelReplace the refresh/start header actions with:
<DemoTimelineControls {...controller} /> {controller.state.mode === "replay" && ( <p className="demo-replay-label" role="status"> Recorded replay · {controller.recordingId} </p> )} <DemoTimeline state={controller.state} />Keep existing issue selection, final markdown, created issues, and trace table. Read their data from
controller.interruptPayload,controller.output, andcontroller.trace.At review:
Resume and create selected issuesis labeledContinuein replay mode;- replay copy states that no real issue will be created;
- live mode retains the concrete action label.
Missing deployment instructions appear only in live mode.
-
Step 5: Update App integration
In
App.tsx:import { useDemoTimeline } from "../demo/useDemoTimeline.js"; const demoController = useDemoTimeline(connectedTarget, recordEvidence);Remove the old
useLdaReportDemoimport. Mount the panel regardless of connection state so replay is available when the RPC server is down:<LdaReportDemoPanel controller={demoController} />Live mode with
target === nullshows a concise connection-required state and disables Start. Replay remains enabled and fully operable. -
Step 6: Add focused styles
Add styles for:
.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-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); } -
Step 7: Run focused tests and typecheck
Run:
pnpm --dir web --filter @lda/console test -- src/demo src/app/App.test.tsx pnpm --dir web --filter @lda/console typecheckExpected: all pass and
rg -n 'useLdaReportDemo' web/apps/console/srcreturns no matches.Add an App test that leaves
connectToServerunresolved or failed, switches the panel to Replay, and assertsStart presentationis enabled without a connected target. -
Step 8: Commit Tasks 4 And 5
git add web/apps/console/src/demo web/apps/console/src/app/App.tsx web/apps/console/src/app/App.test.tsx web/apps/console/src/styles/global.css git commit -m "feat: add live and replay demo timeline"
Task 6: Docs, Full Verification, And Live/Offline Smoke
Files:
- Modify:
web/README.md - Modify:
docs/current_roadmap.md - Move:
docs/superpowers/plans/2026-07-03-demo-autoplay-replay.mdtodocs/historical/superpowers/plans/2026-07-03-demo-autoplay-replay.md
Interfaces:
-
Produces the operator runbook and final evidence for this slice.
-
Step 1: Document controls and recording honesty
Add to
web/README.md:### 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. -
Step 2: Update roadmap and archive plan
Change roadmap item 6 to completed and link the historical implementation plan. Move this plan to
docs/historical/superpowers/plans/. -
Step 3: Run full verification
Run:
pnpm --dir web test pnpm --dir web typecheck pnpm --dir web build git diff --checkExpected: all pass; only expected Windows CRLF warnings may appear.
-
Step 4: Run live browser smoke
Start the example server and web app:
uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765 pnpm --dir web devVerify in Chromium:
- Select Live.
- Start presentation.
- Pause and confirm no new RPC request appears.
- Play and confirm the timeline stops at
issue_review. - Select one issue and continue.
- Confirm created issue, markdown, trace, and evidence render.
- Restart.
-
Step 5: Run replay smoke without RPC
Stop
wf-rpc-server, reload the console page while the web app remains available, select Replay, and verify:Recorded replay · lda-report-success-v1is visible.- Start presentation advances the timeline.
- Playback stops at review.
- Continue completes the same report/issue/trace story.
- Browser requests contain no
/api/rpccalls during replay playback.
-
Step 6: Commit docs
git add web/README.md docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-03-demo-autoplay-replay.md git commit -m "docs: document demo autoplay and replay"
Self-Review
Spec coverage: Tasks cover normalized events, live one-step execution, schema-validated committed replay, Play/Pause/Next, mandatory human review, Restart, visible attribution, shared rendering, failure behavior, docs, and live/offline smoke verification.
Placeholder scan: The plan contains no placeholder implementation gaps. The recording step defines exact metadata, stage order, sanitization rules, and required payload content while leaving only deterministic fixture values to be transcribed.
Type consistency: DemoEvent, DemoRecording, DemoTimelineState,
LiveDemoContext, and DemoTimelineController are defined once and consumed by
later tasks with matching names. Playback uses appliedCount as an internal
position, while event stage carries presentation meaning.