> **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 `stage` carries 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 `useDemoTimeline` instead of `useLdaReportDemo`. - 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`, and `decodeDemoRecording`. - 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`: ```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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/timeline/reducer.test.ts ``` Expected: FAIL because timeline modules do not exist. - [ ] **Step 3: Implement schemas and types** Create `web/apps/console/src/demo/timeline/models.ts`: ```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; export type DemoEvent = v.InferOutput; export type DemoRecording = v.InferOutput; 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`: ```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; 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 } | { 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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/timeline/reducer.test.ts pnpm --dir web --filter @lda/console typecheck ``` Expected: all pass. - [ ] **Step 6: Commit** ```powershell 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`, and `executeLiveDemoStep(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.ts` with mocked `callOperation` and these cases: ```ts 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 `interruptedStartResult` in the test with the same interpreted shape already used by `useLdaReportDemo.test.tsx`. - [ ] **Step 2: Run tests to verify RED** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/timeline/live.test.ts ``` Expected: FAIL because `live.ts` does not exist. - [ ] **Step 3: Implement live context and event conversion** Create `web/apps/console/src/demo/timeline/live.ts` with: ```ts 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; readonly comment: string; readonly outcome?: "submitted" | "cancelled"; }; export type LiveStepResult = { readonly context: LiveDemoContext; readonly events: ReadonlyArray; }; ``` Add private helpers: ```ts 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 `executeLiveDemoStep` as an explicit switch. Each branch performs one RPC call, creates events, and advances `nextStage`. Required behavior: ```ts export const executeLiveDemoStep = async ( target: string, context: LiveDemoContext, approval?: DemoApproval, ): Promise => { 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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/timeline/live.test.ts pnpm --dir web --filter @lda/console typecheck ``` Expected: all pass. - [ ] **Step 6: Commit** ```powershell 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()` and `nextReplayEvent(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`: ```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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/timeline/replay.test.ts ``` Expected: 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.json` with this sanitized fixture: ```json { "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 ''", "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 ''", "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`: ```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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/timeline/replay.test.ts pnpm --dir web --filter @lda/console typecheck ``` Expected: all pass. - [ ] **Step 6: Commit** ```powershell 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`, and `restart`. - 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.tsx` and cover: ```ts 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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/useDemoTimeline.test.tsx ``` Expected: FAIL because the hook does not exist. - [ ] **Step 3: Implement controller** Implement `useDemoTimeline` with: - `useReducer(demoTimelineReducer, initialDemoTimelineState)`; - `useRef` for live context, active approval, in-flight guard, and `recordEvidence`; - a `step()` callback that executes one live operation or applies one replay event; - one 900 ms autoplay timer in `useEffect` only while phase is `running`, autoplay is true, and no step is in flight; - event projection that updates `interruptPayload`, `output`, and `trace` from event `interpreted` data; - 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: ```ts 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; readonly submitSelectedIssues: ( selectedIssueIds: ReadonlyArray, comment: string, ) => Promise; readonly cancelReview: (comment: string) => Promise; readonly restart: () => void; }; ``` When a live operation succeeds, forward its evidence to the existing `recordEvidence` callback. 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: ```text web/apps/console/src/demo/useLdaReportDemo.ts web/apps/console/src/demo/useLdaReportDemo.test.tsx ``` Search for stale imports: ```powershell rg -n 'useLdaReportDemo' web/apps/console/src ``` Expected after Task 5 integration: no matches. - [ ] **Step 5: Run controller tests and typecheck** Run: ```powershell pnpm --dir web --filter @lda/console test -- src/demo/useDemoTimeline.test.tsx pnpm --dir web --filter @lda/console typecheck ``` Expected: 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 `DemoTimelineController` from Task 4. - Produces one shared Live/Replay panel with visible attribution and deterministic controls. - [ ] **Step 1: Write control/component tests** Add tests for: ```ts 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 `DemoTimelineControls`** Component props: ```ts type DemoTimelineControlsProps = Pick< DemoTimelineController, "state" | "setMode" | "start" | "pause" | "play" | "next" | "restart" >; ``` Mode buttons are disabled once playback leaves ready state. `Next` awaits the returned promise and is disabled while the controller reports an in-flight step. - [ ] **Step 3: Implement `DemoTimeline`** Render applied events plus the next pending event: ```tsx
    {state.events.map((event, index) => { const status = index < state.appliedCount ? "complete" : index === state.appliedCount ? "current" : "pending"; return (
  1. {event.stage.replaceAll("_", " ")} {event.reason}
  2. ); })}
``` - [ ] **Step 4: Update `LdaReportDemoPanel`** Replace the refresh/start header actions with: ```tsx {controller.state.mode === "replay" && (

Recorded replay · {controller.recordingId}

)} ``` Keep existing issue selection, final markdown, created issues, and trace table. Read their data from `controller.interruptPayload`, `controller.output`, and `controller.trace`. At review: - `Resume and create selected issues` is labeled `Continue` in 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`: ```ts import { useDemoTimeline } from "../demo/useDemoTimeline.js"; const demoController = useDemoTimeline(connectedTarget, recordEvidence); ``` Remove the old `useLdaReportDemo` import. Mount the panel regardless of connection state so replay is available when the RPC server is down: ```tsx ``` Live mode with `target === null` shows a concise connection-required state and disables Start. Replay remains enabled and fully operable. - [ ] **Step 6: Add focused styles** Add styles for: ```css .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: ```powershell pnpm --dir web --filter @lda/console test -- src/demo src/app/App.test.tsx pnpm --dir web --filter @lda/console typecheck ``` Expected: all pass and `rg -n 'useLdaReportDemo' web/apps/console/src` returns no matches. Add an App test that leaves `connectToServer` unresolved or failed, switches the panel to Replay, and asserts `Start presentation` is enabled without a connected target. - [ ] **Step 8: Commit Tasks 4 And 5** ```powershell 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.md` to `docs/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`: ```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: ```powershell pnpm --dir web test pnpm --dir web typecheck pnpm --dir web build git diff --check ``` Expected: all pass; only expected Windows CRLF warnings may appear. - [ ] **Step 4: Run live browser smoke** Start the example server and web app: ```powershell uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765 pnpm --dir web dev ``` Verify in Chromium: 1. Select Live. 2. Start presentation. 3. Pause and confirm no new RPC request appears. 4. Play and confirm the timeline stops at `issue_review`. 5. Select one issue and continue. 6. Confirm created issue, markdown, trace, and evidence render. 7. 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: 1. `Recorded replay · lda-report-success-v1` is visible. 2. Start presentation advances the timeline. 3. Playback stops at review. 4. Continue completes the same report/issue/trace story. 5. Browser requests contain no `/api/rpc` calls during replay playback. - [ ] **Step 6: Commit docs** ```powershell 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.