feat: prime replay timeline for demo beats

This commit is contained in:
lda
2026-07-09 15:15:50 +07:00 Verified
parent eaf2436cb2
commit e2a658e1f2
3 changed files with 115 additions and 2 deletions
+16 -1
View File
@@ -36,7 +36,13 @@ export type DemoTimelineAction =
| { readonly type: "play" } | { readonly type: "play" }
| { readonly type: "continue_review" } | { readonly type: "continue_review" }
| { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent } | { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent }
| { readonly type: "restart" }; | { readonly type: "restart" }
| {
readonly type: "prime_replay";
readonly events: ReadonlyArray<DemoEvent>;
readonly appliedCount: number;
readonly phase: DemoTimelinePhase;
};
const phaseAfterEvent = (event: DemoEvent): DemoTimelinePhase => { const phaseAfterEvent = (event: DemoEvent): DemoTimelinePhase => {
if (event.stage === "interrupt") return "review"; if (event.stage === "interrupt") return "review";
@@ -106,6 +112,15 @@ export const demoTimelineReducer = (
}; };
case "restart": case "restart":
return { ...initialDemoTimelineState, mode: state.mode }; return { ...initialDemoTimelineState, mode: state.mode };
case "prime_replay":
return {
mode: "replay",
phase: action.phase,
events: action.events,
appliedCount: action.appliedCount,
autoplay: false,
error: null,
};
default: default:
return state; return state;
} }
@@ -399,4 +399,39 @@ describe("useDemoTimeline", () => {
const { result } = renderHook(() => useDemoTimeline(null, vi.fn())); const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
expect(result.current.missingDeploymentMessage).toContain("Not connected"); expect(result.current.missingDeploymentMessage).toContain("Not connected");
}); });
it("primes replay to the interrupt stage immediately", () => {
const { result } = renderHook(() => useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()));
act(() => result.current.start("replay"));
act(() => result.current.primeReplayToStage("interrupt"));
expect(result.current.state.mode).toBe("replay");
expect(result.current.state.phase).toBe("review");
expect(result.current.interruptPayload).not.toBeNull();
expect(result.current.state.events[result.current.state.appliedCount - 1]?.stage).toBe("interrupt");
});
it("primes replay to the resume stage with output projection", () => {
const { result } = renderHook(() => useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()));
act(() => result.current.start("replay"));
act(() => result.current.primeReplayToStage("run_resume"));
expect(result.current.state.mode).toBe("replay");
expect(result.current.state.phase).toBe("paused");
expect(result.current.output?.created_issues).toHaveLength(1);
expect(result.current.state.events[result.current.state.appliedCount - 1]?.stage).toBe("run_resume");
});
it("does not prime live timelines", () => {
const { result } = renderHook(() => useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()));
act(() => result.current.start("live"));
act(() => result.current.primeReplayToStage("interrupt"));
expect(result.current.state.mode).toBe("live");
expect(result.current.state.events).toEqual([]);
expect(result.current.interruptPayload).toBeNull();
});
}); });
+64 -1
View File
@@ -13,7 +13,7 @@ import {
type DemoMode, type DemoMode,
type DemoTimelineState, type DemoTimelineState,
} from "./timeline/reducer.js"; } from "./timeline/reducer.js";
import type { DemoRecording } from "./timeline/models.js"; import type { DemoEvent, DemoRecording } from "./timeline/models.js";
import { import {
executeLiveDemoStep, executeLiveDemoStep,
failedLiveDemoEvent, failedLiveDemoEvent,
@@ -45,6 +45,7 @@ export type DemoTimelineController = {
) => Promise<void>; ) => Promise<void>;
readonly cancelReview: (comment: string) => Promise<void>; readonly cancelReview: (comment: string) => Promise<void>;
readonly restart: () => void; readonly restart: () => void;
readonly primeReplayToStage: (stage: DemoEvent["stage"] | null) => void;
}; };
const deriveRecordingId = (state: DemoTimelineState): string | null => const deriveRecordingId = (state: DemoTimelineState): string | null =>
@@ -55,6 +56,23 @@ const deriveMissingMessage = (mode: DemoMode, target: string | null): string | n
return "Not connected. Select Live and connect to a workflow server, or switch to Replay."; return "Not connected. Select Live and connect to a workflow server, or switch to Replay.";
}; };
const phaseForPrimedStage = (
stage: DemoEvent["stage"] | null,
): DemoTimelineState["phase"] => {
if (stage === "interrupt") return "review";
if (stage === "completed") return "completed";
if (stage === "failed") return "failed";
return "paused";
};
const appliedCountForStage = (
events: ReadonlyArray<DemoEvent>,
stage: DemoEvent["stage"],
): number => {
const index = events.findIndex((event) => event.stage === stage);
return index === -1 ? 0 : index + 1;
};
export const useDemoTimeline = ( export const useDemoTimeline = (
target: string | null, target: string | null,
recordEvidence: EvidenceRecorder, recordEvidence: EvidenceRecorder,
@@ -88,6 +106,31 @@ export const useDemoTimeline = (
setTrace(null); setTrace(null);
}, []); }, []);
const projectTransientState = useCallback((events: ReadonlyArray<DemoEvent>, appliedCount: number) => {
const appliedEvents = events.slice(0, appliedCount);
const interruptEvent = appliedEvents.find((event) => event.stage === "interrupt");
const resumeEvent = appliedEvents.find((event) => event.stage === "run_resume");
const traceEvent = appliedEvents.find((event) => event.stage === "trace_read");
const completedEvent = appliedEvents.find((event) => event.stage === "completed");
if (interruptEvent?.interpreted) {
const interpreted = interruptEvent.interpreted as { payload: LdaReportInterruptPayload };
setInterruptPayload(parseLdaReportInterruptPayload(interpreted.payload));
}
if (resumeEvent?.interpreted) {
const interpreted = resumeEvent.interpreted as { output: LdaReportOutput };
setOutput(parseLdaReportOutput(interpreted.output));
}
if (traceEvent?.interpreted) {
setTrace(decodeTracePage(traceEvent.interpreted));
}
if (completedEvent?.interpreted) {
const interpreted = completedEvent.interpreted as { output: LdaReportOutput; trace: TracePage };
setOutput(parseLdaReportOutput(interpreted.output));
setTrace(decodeTracePage(interpreted.trace));
}
}, []);
useEffect(() => { useEffect(() => {
resetRuntime(); resetRuntime();
}, [target, resetRuntime]); }, [target, resetRuntime]);
@@ -254,6 +297,25 @@ export const useDemoTimeline = (
dispatch({ type: "restart" }); dispatch({ type: "restart" });
}, [resetRuntime]); }, [resetRuntime]);
const primeReplayToStage = useCallback((stage: DemoEvent["stage"] | null) => {
if (stage === null || state.mode !== "replay") return;
const recording = activeRecording.current;
if (!recording) return;
const appliedCount = appliedCountForStage(recording.events, stage);
if (appliedCount === 0) return;
// Presentation priming projects the reviewed recording to the current beat.
// It is not runtime execution and must never run while the live timeline is active.
resetRuntime();
projectTransientState(recording.events, appliedCount);
dispatch({
type: "prime_replay",
events: recording.events,
appliedCount,
phase: phaseForPrimedStage(stage),
});
}, [projectTransientState, resetRuntime, state.mode]);
return { return {
state, state,
inFlight, inFlight,
@@ -271,5 +333,6 @@ export const useDemoTimeline = (
submitSelectedIssues, submitSelectedIssues,
cancelReview, cancelReview,
restart, restart,
primeReplayToStage,
}; };
}; };