feat: add demo timeline replay controls
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { callOperation } from "../../connection/api.js";
|
||||
import {
|
||||
executeLiveDemoStep,
|
||||
initialLiveDemoContext,
|
||||
} from "./live.js";
|
||||
|
||||
vi.mock("../../connection/api.js", () => ({ callOperation: vi.fn() }));
|
||||
const mockedCallOperation = vi.mocked(callOperation);
|
||||
|
||||
beforeEach(() => mockedCallOperation.mockReset());
|
||||
|
||||
const interruptedStartResult = {
|
||||
ok: true as const,
|
||||
operation: "workflow.runs.start" as const,
|
||||
label: "Start run",
|
||||
interpreted: {
|
||||
runId: "run_demo",
|
||||
deploymentId: "lda_report_case_study.default",
|
||||
artifactId: "lda_report_case_study",
|
||||
artifactVersion: 1,
|
||||
status: "interrupted",
|
||||
resumeReadiness: "ready",
|
||||
interrupt: {
|
||||
kind: "issue_review",
|
||||
payload: {
|
||||
report_markdown: "# Report",
|
||||
proposed_issues: [
|
||||
{ id: "risk-1", title: "Prepare defense", body: "Review paths.", severity: "medium" },
|
||||
],
|
||||
},
|
||||
outcomes: ["submitted", "cancelled"],
|
||||
typed: true,
|
||||
request_schema: { type: "object" },
|
||||
resume_schema: { type: "object" },
|
||||
},
|
||||
outcome: null,
|
||||
error: null,
|
||||
output: null,
|
||||
diagnostics: [],
|
||||
traceCount: 6,
|
||||
nextActions: {
|
||||
canContinue: true,
|
||||
canSaveNow: null,
|
||||
recommendedNextTool: "wf.workflow.resume_run",
|
||||
reason: "Run is interrupted for issue review.",
|
||||
patchExamples: [],
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
|
||||
durationMs: 88,
|
||||
};
|
||||
|
||||
const completedResumeResult = {
|
||||
ok: true as const,
|
||||
operation: "workflow.runs.resume" as const,
|
||||
label: "Resume run",
|
||||
interpreted: {
|
||||
runId: "run_demo",
|
||||
deploymentId: "lda_report_case_study.default",
|
||||
artifactId: "lda_report_case_study",
|
||||
artifactVersion: 1,
|
||||
status: "completed",
|
||||
resumeReadiness: "not_applicable",
|
||||
interrupt: null,
|
||||
outcome: "completed",
|
||||
error: null,
|
||||
output: {
|
||||
approved: true,
|
||||
markdown: "# Report",
|
||||
created_issues: [{ id: "ISSUE-001", title: "Defense", url: "local://issues/ISSUE-001" }],
|
||||
selected_issue_ids: ["risk-1"],
|
||||
comment: "Create it.",
|
||||
},
|
||||
diagnostics: [],
|
||||
traceCount: 10,
|
||||
nextActions: {
|
||||
canContinue: false,
|
||||
canSaveNow: null,
|
||||
recommendedNextTool: null,
|
||||
reason: "Run completed.",
|
||||
patchExamples: [],
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
|
||||
durationMs: 63,
|
||||
};
|
||||
|
||||
const traceResult = {
|
||||
ok: true as const,
|
||||
operation: "workflow.runs.trace" as const,
|
||||
label: "Read trace",
|
||||
interpreted: {
|
||||
runId: "run_demo",
|
||||
status: "completed",
|
||||
frames: [
|
||||
{ nodeId: "list_documents", stepType: "node", outcome: "ok", resolvedInput: {}, output: {}, stateChanges: {} },
|
||||
{ nodeId: "review_issues", stepType: "interrupt", outcome: "submitted", resolvedInput: {}, output: {}, stateChanges: {} },
|
||||
{ nodeId: "finalise_report", stepType: "node", outcome: "completed", resolvedInput: {}, output: {}, stateChanges: {} },
|
||||
],
|
||||
traceStart: 0,
|
||||
traceLimit: 50,
|
||||
traceTruncated: false,
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf run trace run_demo --from 0 --limit 50",
|
||||
durationMs: 12,
|
||||
};
|
||||
|
||||
describe("executeLiveDemoStep", () => {
|
||||
it("executes exactly one deployment check", async () => {
|
||||
mockedCallOperation.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
operation: "workflow.deployments.inspect" as const,
|
||||
label: "Inspect deployment",
|
||||
interpreted: {
|
||||
id: "lda_report_case_study.default",
|
||||
artifactId: "lda_report_case_study",
|
||||
artifactVersion: 1,
|
||||
bindings: [],
|
||||
driftPolicy: "block",
|
||||
},
|
||||
exchange: { request: {}, response: {} },
|
||||
equivalentCli: "uv run wf deploy inspect lda_report_case_study.default",
|
||||
durationMs: 4,
|
||||
});
|
||||
|
||||
const result = await executeLiveDemoStep(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
initialLiveDemoContext,
|
||||
);
|
||||
|
||||
expect(mockedCallOperation).toHaveBeenCalledOnce();
|
||||
expect(result.events[0]?.stage).toBe("deployment_check");
|
||||
expect(result.context.nextStage).toBe("run_start");
|
||||
});
|
||||
|
||||
it("stops at issue_review after run start", async () => {
|
||||
mockedCallOperation.mockResolvedValueOnce(interruptedStartResult);
|
||||
const result = await executeLiveDemoStep(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
{ ...initialLiveDemoContext, nextStage: "run_start" },
|
||||
);
|
||||
expect(result.events.map((event) => event.stage)).toEqual([
|
||||
"run_start",
|
||||
"interrupt",
|
||||
]);
|
||||
expect(result.context.nextStage).toBe("run_resume");
|
||||
expect(result.context.runId).toBe("run_demo");
|
||||
});
|
||||
|
||||
it("does not retry failed mutations", async () => {
|
||||
mockedCallOperation.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: "rpc_remote_error", message: "resume failed" },
|
||||
exchange: { request: {}, response: {} },
|
||||
});
|
||||
const result = await executeLiveDemoStep(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
{ ...initialLiveDemoContext, nextStage: "run_resume", runId: "run_demo" },
|
||||
{ approved: true, selectedIssueIds: ["risk-1"], comment: "Create it" },
|
||||
);
|
||||
expect(mockedCallOperation).toHaveBeenCalledOnce();
|
||||
expect(result.events.at(-1)?.stage).toBe("failed");
|
||||
});
|
||||
|
||||
it("resumes and returns run_resume event", async () => {
|
||||
mockedCallOperation.mockResolvedValueOnce(completedResumeResult);
|
||||
const result = await executeLiveDemoStep(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
{ ...initialLiveDemoContext, nextStage: "run_resume", runId: "run_demo" },
|
||||
{ approved: true, selectedIssueIds: ["risk-1"], comment: "Create it" },
|
||||
);
|
||||
expect(result.events[0]?.stage).toBe("run_resume");
|
||||
expect(result.context.nextStage).toBe("trace_read");
|
||||
expect(result.context.output).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reads trace and emits completed event", async () => {
|
||||
mockedCallOperation.mockResolvedValueOnce(traceResult);
|
||||
const result = await executeLiveDemoStep(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
{
|
||||
...initialLiveDemoContext,
|
||||
nextStage: "trace_read",
|
||||
runId: "run_demo",
|
||||
output: {
|
||||
approved: true,
|
||||
markdown: "# Report",
|
||||
created_issues: [],
|
||||
selected_issue_ids: [],
|
||||
comment: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.events.map((event) => event.stage)).toEqual([
|
||||
"trace_read",
|
||||
"completed",
|
||||
]);
|
||||
expect(result.context.nextStage).toBe("done");
|
||||
});
|
||||
|
||||
it("returns empty events when done", async () => {
|
||||
const result = await executeLiveDemoStep(
|
||||
"http://127.0.0.1:8765/rpc",
|
||||
{ ...initialLiveDemoContext, nextStage: "done" },
|
||||
);
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.context.nextStage).toBe("done");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { callOperation } from "../../connection/api.js";
|
||||
import type { RpcResponse } from "../../connection/contracts.js";
|
||||
import { decodeRunDetail, decodeTracePage, type TracePage } from "../../lifecycle/models.js";
|
||||
import {
|
||||
LDA_REPORT_DEPLOYMENT_ID,
|
||||
LDA_REPORT_INTERRUPT_KIND,
|
||||
ldaReportDemoInput,
|
||||
} from "../ldaReportDemoConfig.js";
|
||||
import {
|
||||
parseLdaReportInterruptPayload,
|
||||
parseLdaReportOutput,
|
||||
type LdaReportInterruptPayload,
|
||||
type LdaReportOutput,
|
||||
} from "../ldaReportDemoModels.js";
|
||||
import type { DemoEvent, DemoEventStage } from "./models.js";
|
||||
|
||||
export type LiveDemoStage =
|
||||
| "deployment_check"
|
||||
| "run_start"
|
||||
| "run_resume"
|
||||
| "trace_read"
|
||||
| "done";
|
||||
|
||||
export type LiveDemoContext = {
|
||||
readonly nextStage: LiveDemoStage;
|
||||
readonly nextSequence: number;
|
||||
readonly runId: string | null;
|
||||
readonly interruptPayload: LdaReportInterruptPayload | null;
|
||||
readonly output: LdaReportOutput | null;
|
||||
readonly trace: TracePage | null;
|
||||
};
|
||||
|
||||
export const initialLiveDemoContext: LiveDemoContext = {
|
||||
nextStage: "deployment_check",
|
||||
nextSequence: 0,
|
||||
runId: null,
|
||||
interruptPayload: null,
|
||||
output: null,
|
||||
trace: null,
|
||||
};
|
||||
|
||||
export type DemoApproval = {
|
||||
readonly approved: boolean;
|
||||
readonly selectedIssueIds: ReadonlyArray<string>;
|
||||
readonly comment: string;
|
||||
readonly outcome?: "submitted" | "cancelled";
|
||||
};
|
||||
|
||||
export type LiveStepResult = {
|
||||
readonly context: LiveDemoContext;
|
||||
readonly events: ReadonlyArray<DemoEvent>;
|
||||
};
|
||||
|
||||
const eventFromResult = (
|
||||
context: LiveDemoContext,
|
||||
stage: DemoEventStage,
|
||||
operation: string,
|
||||
reason: string,
|
||||
params: unknown,
|
||||
result: RpcResponse,
|
||||
runId: string | null,
|
||||
): DemoEvent => ({
|
||||
id: `live-${context.nextSequence}-${stage}-${runId ?? "pending"}`,
|
||||
sequence: context.nextSequence,
|
||||
stage,
|
||||
operation,
|
||||
reason: result.ok ? reason : result.error.message,
|
||||
equivalentCli: result.ok ? result.equivalentCli : null,
|
||||
params,
|
||||
rawResponse: result.exchange.response,
|
||||
interpreted: result.ok ? result.interpreted : null,
|
||||
durationMs: result.ok ? result.durationMs : 0,
|
||||
resultingIds: {
|
||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
||||
runId,
|
||||
},
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const syntheticEvent = (
|
||||
context: LiveDemoContext,
|
||||
stage: "interrupt" | "completed" | "failed",
|
||||
reason: string,
|
||||
interpreted: unknown,
|
||||
runId: string | null,
|
||||
sequenceOffset = 0,
|
||||
): DemoEvent => ({
|
||||
id: `live-${context.nextSequence + sequenceOffset}-${stage}-${runId ?? "pending"}`,
|
||||
sequence: context.nextSequence + sequenceOffset,
|
||||
stage,
|
||||
operation: null,
|
||||
reason,
|
||||
equivalentCli: null,
|
||||
params: {},
|
||||
rawResponse: null,
|
||||
interpreted,
|
||||
durationMs: 0,
|
||||
resultingIds: { deploymentId: LDA_REPORT_DEPLOYMENT_ID, runId },
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const operationForStage = (stage: LiveDemoStage): string | null => {
|
||||
switch (stage) {
|
||||
case "deployment_check":
|
||||
return "workflow.deployments.inspect";
|
||||
case "run_start":
|
||||
return "workflow.runs.start";
|
||||
case "run_resume":
|
||||
return "workflow.runs.resume";
|
||||
case "trace_read":
|
||||
return "workflow.runs.trace";
|
||||
case "done":
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const failedLiveDemoEvent = (
|
||||
context: LiveDemoContext,
|
||||
reason: string,
|
||||
): DemoEvent => ({
|
||||
id: `live-${context.nextSequence}-failed-${context.runId ?? "pending"}`,
|
||||
sequence: context.nextSequence,
|
||||
stage: "failed",
|
||||
operation: operationForStage(context.nextStage),
|
||||
reason,
|
||||
equivalentCli: null,
|
||||
params: {},
|
||||
rawResponse: null,
|
||||
interpreted: null,
|
||||
durationMs: 0,
|
||||
resultingIds: {
|
||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
||||
runId: context.runId,
|
||||
},
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const executeLiveDemoStep = async (
|
||||
target: string,
|
||||
context: LiveDemoContext,
|
||||
approval?: DemoApproval,
|
||||
): Promise<LiveStepResult> => {
|
||||
switch (context.nextStage) {
|
||||
case "deployment_check": {
|
||||
const params = { deployment_id: LDA_REPORT_DEPLOYMENT_ID };
|
||||
const result = await callOperation("workflow.deployments.inspect", target, params);
|
||||
const event = eventFromResult(
|
||||
context,
|
||||
result.ok ? "deployment_check" : "failed",
|
||||
"workflow.deployments.inspect",
|
||||
"Confirm the prepared report deployment exists.",
|
||||
params,
|
||||
result,
|
||||
null,
|
||||
);
|
||||
return {
|
||||
events: [event],
|
||||
context: result.ok
|
||||
? { ...context, nextStage: "run_start", nextSequence: context.nextSequence + 1 }
|
||||
: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
|
||||
};
|
||||
}
|
||||
case "run_start": {
|
||||
const params = {
|
||||
deployment_id: LDA_REPORT_DEPLOYMENT_ID,
|
||||
workflow_input: ldaReportDemoInput,
|
||||
trace_range: { start: 0, limit: 50 },
|
||||
};
|
||||
const result = await callOperation("workflow.runs.start", target, params);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
events: [eventFromResult(context, "failed", "workflow.runs.start", "Start the prepared run.", params, result, null)],
|
||||
context: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
|
||||
};
|
||||
}
|
||||
const detail = decodeRunDetail(result.interpreted);
|
||||
if (detail.status !== "interrupted" || detail.interrupt?.kind !== LDA_REPORT_INTERRUPT_KIND) {
|
||||
const failed = syntheticEvent(
|
||||
context,
|
||||
"failed",
|
||||
"Demo run did not stop at issue_review interrupt.",
|
||||
result.interpreted,
|
||||
detail.runId,
|
||||
);
|
||||
return {
|
||||
events: [failed],
|
||||
context: { ...context, nextStage: "done", runId: detail.runId, nextSequence: context.nextSequence + 1 },
|
||||
};
|
||||
}
|
||||
const payload = parseLdaReportInterruptPayload(detail.interrupt.payload);
|
||||
const startEvent = eventFromResult(
|
||||
context,
|
||||
"run_start",
|
||||
"workflow.runs.start",
|
||||
"Start the prepared report workflow.",
|
||||
params,
|
||||
result,
|
||||
detail.runId,
|
||||
);
|
||||
const interruptEvent = syntheticEvent(
|
||||
context,
|
||||
"interrupt",
|
||||
"Pause for typed issue review.",
|
||||
{ payload, outcomes: detail.interrupt.outcomes },
|
||||
detail.runId,
|
||||
1,
|
||||
);
|
||||
return {
|
||||
events: [startEvent, interruptEvent],
|
||||
context: {
|
||||
...context,
|
||||
nextStage: "run_resume",
|
||||
runId: detail.runId,
|
||||
interruptPayload: payload,
|
||||
nextSequence: context.nextSequence + 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "run_resume": {
|
||||
if (!context.runId || !approval) {
|
||||
throw new Error("run_resume requires a run id and explicit approval");
|
||||
}
|
||||
const params = {
|
||||
run_id: context.runId,
|
||||
resume_payload: {
|
||||
approved: approval.approved,
|
||||
selected_issue_ids: [...approval.selectedIssueIds],
|
||||
comment: approval.comment,
|
||||
},
|
||||
resume_outcome: approval.outcome ?? (approval.approved ? "submitted" : "cancelled"),
|
||||
trace_range: { start: 0, limit: 50 },
|
||||
};
|
||||
const result = await callOperation("workflow.runs.resume", target, params);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
events: [eventFromResult(context, "failed", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
|
||||
context: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
|
||||
};
|
||||
}
|
||||
const detail = decodeRunDetail(result.interpreted);
|
||||
const output = parseLdaReportOutput(detail.output);
|
||||
return {
|
||||
events: [eventFromResult(context, "run_resume", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
|
||||
context: {
|
||||
...context,
|
||||
nextStage: "trace_read",
|
||||
output,
|
||||
nextSequence: context.nextSequence + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "trace_read": {
|
||||
if (!context.runId || !context.output) {
|
||||
throw new Error("trace_read requires a completed run and output");
|
||||
}
|
||||
const params = { run_id: context.runId, trace_range: { start: 0, limit: 50 } };
|
||||
const result = await callOperation("workflow.runs.trace", target, params);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
events: [eventFromResult(context, "failed", "workflow.runs.trace", "Read the final run trace.", params, result, context.runId)],
|
||||
context: { ...context, nextStage: "done", nextSequence: context.nextSequence + 1 },
|
||||
};
|
||||
}
|
||||
const trace = decodeTracePage(result.interpreted);
|
||||
const traceEvent = eventFromResult(
|
||||
context,
|
||||
"trace_read",
|
||||
"workflow.runs.trace",
|
||||
"Read the final run trace.",
|
||||
params,
|
||||
result,
|
||||
context.runId,
|
||||
);
|
||||
const completedEvent = syntheticEvent(
|
||||
context,
|
||||
"completed",
|
||||
"The prepared report demo completed.",
|
||||
{ output: context.output, trace },
|
||||
context.runId,
|
||||
1,
|
||||
);
|
||||
return {
|
||||
events: [traceEvent, completedEvent],
|
||||
context: {
|
||||
...context,
|
||||
nextStage: "done",
|
||||
trace,
|
||||
nextSequence: context.nextSequence + 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "done":
|
||||
return { context, events: [] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as v from "valibot";
|
||||
|
||||
export const DemoEventStageSchema = v.picklist([
|
||||
"deployment_check",
|
||||
"run_start",
|
||||
"interrupt",
|
||||
"run_resume",
|
||||
"trace_read",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
const ResultingIdsSchema = v.object({
|
||||
deploymentId: v.nullable(v.string()),
|
||||
runId: v.nullable(v.string()),
|
||||
});
|
||||
|
||||
export const DemoEventSchema = v.object({
|
||||
id: v.string(),
|
||||
sequence: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
||||
stage: DemoEventStageSchema,
|
||||
operation: v.nullable(v.string()),
|
||||
reason: v.string(),
|
||||
equivalentCli: v.nullable(v.string()),
|
||||
params: v.unknown(),
|
||||
rawResponse: v.unknown(),
|
||||
interpreted: v.unknown(),
|
||||
durationMs: v.pipe(v.number(), v.minValue(0)),
|
||||
resultingIds: ResultingIdsSchema,
|
||||
recordedAt: v.string(),
|
||||
});
|
||||
|
||||
export const DemoRecordingSchema = v.object({
|
||||
schemaVersion: v.literal(1),
|
||||
recordingId: v.string(),
|
||||
title: v.string(),
|
||||
createdAt: v.string(),
|
||||
deploymentId: v.literal("lda_report_case_study.default"),
|
||||
source: v.literal("reviewed_live_capture"),
|
||||
events: v.array(DemoEventSchema),
|
||||
});
|
||||
|
||||
export type DemoEventStage = v.InferOutput<typeof DemoEventStageSchema>;
|
||||
export type DemoEvent = v.InferOutput<typeof DemoEventSchema>;
|
||||
export type DemoRecording = v.InferOutput<typeof DemoRecordingSchema>;
|
||||
|
||||
export const decodeDemoRecording = (value: unknown): DemoRecording => {
|
||||
const recording = v.parse(DemoRecordingSchema, value);
|
||||
recording.events.forEach((event, index) => {
|
||||
if (event.sequence !== index) {
|
||||
throw new Error(`recording event sequence ${event.sequence} does not match index ${index}`);
|
||||
}
|
||||
});
|
||||
return recording;
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
demoTimelineReducer,
|
||||
initialDemoTimelineState,
|
||||
currentDemoEvent,
|
||||
} from "./reducer.js";
|
||||
import type { DemoEvent } from "./models.js";
|
||||
|
||||
const event = (sequence: number, stage: DemoEvent["stage"]): DemoEvent => ({
|
||||
id: `event-${sequence}`,
|
||||
sequence,
|
||||
stage,
|
||||
operation: stage === "interrupt" || stage === "completed" ? null : "workflow.runs.start",
|
||||
reason: `Apply ${stage}`,
|
||||
equivalentCli: null,
|
||||
params: {},
|
||||
rawResponse: {},
|
||||
interpreted: {},
|
||||
durationMs: 1,
|
||||
resultingIds: {
|
||||
deploymentId: "lda_report_case_study.default",
|
||||
runId: sequence > 0 ? "run_demo" : null,
|
||||
},
|
||||
recordedAt: "2026-07-03T00:00:00.000Z",
|
||||
});
|
||||
|
||||
describe("demoTimelineReducer", () => {
|
||||
it("starts live playback in running phase", () => {
|
||||
const state = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "live",
|
||||
events: [],
|
||||
});
|
||||
expect(state.phase).toBe("running");
|
||||
expect(state.autoplay).toBe(true);
|
||||
expect(state.appliedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("applies events in order", () => {
|
||||
const started = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "replay",
|
||||
events: [event(0, "deployment_check"), event(1, "run_start")],
|
||||
});
|
||||
const applied = demoTimelineReducer(started, { type: "apply_next" });
|
||||
expect(applied.appliedCount).toBe(1);
|
||||
expect(currentDemoEvent(applied)?.stage).toBe("deployment_check");
|
||||
});
|
||||
|
||||
it("always pauses at an interrupt", () => {
|
||||
const started = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "replay",
|
||||
events: [event(0, "interrupt")],
|
||||
});
|
||||
const applied = demoTimelineReducer(started, { type: "apply_next" });
|
||||
expect(applied.phase).toBe("review");
|
||||
expect(applied.autoplay).toBe(false);
|
||||
});
|
||||
|
||||
it("stops at completion and failure", () => {
|
||||
for (const stage of ["completed", "failed"] as const) {
|
||||
const started = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "replay",
|
||||
events: [event(0, stage)],
|
||||
});
|
||||
const applied = demoTimelineReducer(started, { type: "apply_next" });
|
||||
expect(applied.phase).toBe(stage);
|
||||
expect(applied.autoplay).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("pause and play preserve playback position", () => {
|
||||
const started = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "replay",
|
||||
events: [event(0, "deployment_check")],
|
||||
});
|
||||
const paused = demoTimelineReducer(started, { type: "pause" });
|
||||
const resumed = demoTimelineReducer(paused, { type: "play" });
|
||||
expect(paused.phase).toBe("paused");
|
||||
expect(resumed.phase).toBe("running");
|
||||
expect(resumed.appliedCount).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps manual playback paused after applying one ordinary event", () => {
|
||||
const started = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "replay",
|
||||
events: [event(0, "deployment_check")],
|
||||
});
|
||||
const paused = demoTimelineReducer(started, { type: "pause" });
|
||||
const applied = demoTimelineReducer(paused, { type: "apply_next" });
|
||||
|
||||
expect(applied.phase).toBe("paused");
|
||||
expect(applied.appliedCount).toBe(1);
|
||||
expect(applied.autoplay).toBe(false);
|
||||
});
|
||||
|
||||
it("restart clears transient playback without deleting mode", () => {
|
||||
const started = demoTimelineReducer(initialDemoTimelineState, {
|
||||
type: "start",
|
||||
mode: "replay",
|
||||
events: [event(0, "completed")],
|
||||
});
|
||||
const restarted = demoTimelineReducer(started, { type: "restart" });
|
||||
expect(restarted.phase).toBe("ready");
|
||||
expect(restarted.mode).toBe("replay");
|
||||
expect(restarted.appliedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { DemoEvent } from "./models.js";
|
||||
|
||||
export type DemoMode = "live" | "replay";
|
||||
export type DemoTimelinePhase =
|
||||
| "ready"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "review"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type DemoTimelineState = {
|
||||
readonly mode: DemoMode;
|
||||
readonly phase: DemoTimelinePhase;
|
||||
readonly events: ReadonlyArray<DemoEvent>;
|
||||
readonly appliedCount: number;
|
||||
readonly autoplay: boolean;
|
||||
readonly error: string | null;
|
||||
};
|
||||
|
||||
export const initialDemoTimelineState: DemoTimelineState = {
|
||||
mode: "live",
|
||||
phase: "ready",
|
||||
events: [],
|
||||
appliedCount: 0,
|
||||
autoplay: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export type DemoTimelineAction =
|
||||
| { readonly type: "set_mode"; readonly mode: DemoMode }
|
||||
| { readonly type: "start"; readonly mode: DemoMode; readonly events: ReadonlyArray<DemoEvent> }
|
||||
| { readonly type: "append_live_event"; readonly event: DemoEvent }
|
||||
| { readonly type: "apply_next" }
|
||||
| { readonly type: "pause" }
|
||||
| { readonly type: "play" }
|
||||
| { readonly type: "continue_review" }
|
||||
| { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent }
|
||||
| { readonly type: "restart" };
|
||||
|
||||
const phaseAfterEvent = (event: DemoEvent): DemoTimelinePhase => {
|
||||
if (event.stage === "interrupt") return "review";
|
||||
if (event.stage === "completed") return "completed";
|
||||
if (event.stage === "failed") return "failed";
|
||||
return "running";
|
||||
};
|
||||
|
||||
const phaseAfterApply = (
|
||||
state: DemoTimelineState,
|
||||
event: DemoEvent,
|
||||
): DemoTimelinePhase => {
|
||||
const eventPhase = phaseAfterEvent(event);
|
||||
if (eventPhase !== "running") return eventPhase;
|
||||
return state.autoplay ? "running" : "paused";
|
||||
};
|
||||
|
||||
export const demoTimelineReducer = (
|
||||
state: DemoTimelineState,
|
||||
action: DemoTimelineAction,
|
||||
): DemoTimelineState => {
|
||||
switch (action.type) {
|
||||
case "set_mode":
|
||||
return { ...initialDemoTimelineState, mode: action.mode };
|
||||
case "start":
|
||||
return {
|
||||
mode: action.mode,
|
||||
phase: "running",
|
||||
events: action.events,
|
||||
appliedCount: 0,
|
||||
autoplay: true,
|
||||
error: null,
|
||||
};
|
||||
case "append_live_event":
|
||||
return { ...state, events: [...state.events, action.event] };
|
||||
case "apply_next": {
|
||||
const event = state.events[state.appliedCount];
|
||||
if (!event) return state;
|
||||
const phase = phaseAfterApply(state, event);
|
||||
return {
|
||||
...state,
|
||||
phase,
|
||||
appliedCount: state.appliedCount + 1,
|
||||
autoplay: phase === "running" ? state.autoplay : false,
|
||||
error: phase === "failed" ? event.reason : null,
|
||||
};
|
||||
}
|
||||
case "pause":
|
||||
return state.phase === "running"
|
||||
? { ...state, phase: "paused", autoplay: false }
|
||||
: state;
|
||||
case "play":
|
||||
return state.phase === "paused"
|
||||
? { ...state, phase: "running", autoplay: true }
|
||||
: state;
|
||||
case "continue_review":
|
||||
return state.phase === "review"
|
||||
? { ...state, phase: "running", autoplay: true }
|
||||
: state;
|
||||
case "fail":
|
||||
return {
|
||||
...state,
|
||||
phase: "failed",
|
||||
events: action.event ? [...state.events, action.event] : state.events,
|
||||
autoplay: false,
|
||||
error: action.message,
|
||||
};
|
||||
case "restart":
|
||||
return { ...initialDemoTimelineState, mode: state.mode };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const currentDemoEvent = (state: DemoTimelineState): DemoEvent | null =>
|
||||
state.appliedCount > 0 ? state.events[state.appliedCount - 1] ?? null : null;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadCanonicalDemoRecording, nextReplayEvent } from "./replay.js";
|
||||
|
||||
vi.mock("../../connection/api.js", () => ({
|
||||
callOperation: vi.fn(() => {
|
||||
throw new Error("replay must not call RPC");
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("canonical demo recording", () => {
|
||||
it("loads a complete reviewed recording", () => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
expect(recording.schemaVersion).toBe(1);
|
||||
expect(recording.deploymentId).toBe("lda_report_case_study.default");
|
||||
expect(recording.events.map((event) => event.stage)).toEqual([
|
||||
"deployment_check",
|
||||
"run_start",
|
||||
"interrupt",
|
||||
"run_resume",
|
||||
"trace_read",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns one replay event by applied count", () => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
expect(nextReplayEvent(recording, 0)?.stage).toBe("deployment_check");
|
||||
expect(nextReplayEvent(recording, recording.events.length)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import recordingText from "../recordings/lda-report-success.v1.json?raw";
|
||||
import { decodeDemoRecording, type DemoEvent, type DemoRecording } from "./models.js";
|
||||
|
||||
export const loadCanonicalDemoRecording = (): DemoRecording => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(recordingText);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`canonical demo recording is not valid JSON: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return decodeDemoRecording(parsed);
|
||||
};
|
||||
|
||||
export const nextReplayEvent = (
|
||||
recording: DemoRecording,
|
||||
appliedCount: number,
|
||||
): DemoEvent | null => recording.events[appliedCount] ?? null;
|
||||
Reference in New Issue
Block a user