feat: prime replay timeline for demo beats
This commit is contained in:
@@ -36,7 +36,13 @@ export type DemoTimelineAction =
|
||||
| { readonly type: "play" }
|
||||
| { readonly type: "continue_review" }
|
||||
| { 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 => {
|
||||
if (event.stage === "interrupt") return "review";
|
||||
@@ -106,6 +112,15 @@ export const demoTimelineReducer = (
|
||||
};
|
||||
case "restart":
|
||||
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:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -399,4 +399,39 @@ describe("useDemoTimeline", () => {
|
||||
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type DemoMode,
|
||||
type DemoTimelineState,
|
||||
} from "./timeline/reducer.js";
|
||||
import type { DemoRecording } from "./timeline/models.js";
|
||||
import type { DemoEvent, DemoRecording } from "./timeline/models.js";
|
||||
import {
|
||||
executeLiveDemoStep,
|
||||
failedLiveDemoEvent,
|
||||
@@ -45,6 +45,7 @@ export type DemoTimelineController = {
|
||||
) => Promise<void>;
|
||||
readonly cancelReview: (comment: string) => Promise<void>;
|
||||
readonly restart: () => void;
|
||||
readonly primeReplayToStage: (stage: DemoEvent["stage"] | null) => void;
|
||||
};
|
||||
|
||||
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.";
|
||||
};
|
||||
|
||||
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 = (
|
||||
target: string | null,
|
||||
recordEvidence: EvidenceRecorder,
|
||||
@@ -88,6 +106,31 @@ export const useDemoTimeline = (
|
||||
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(() => {
|
||||
resetRuntime();
|
||||
}, [target, resetRuntime]);
|
||||
@@ -254,6 +297,25 @@ export const useDemoTimeline = (
|
||||
dispatch({ type: "restart" });
|
||||
}, [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 {
|
||||
state,
|
||||
inFlight,
|
||||
@@ -271,5 +333,6 @@ export const useDemoTimeline = (
|
||||
submitSelectedIssues,
|
||||
cancelReview,
|
||||
restart,
|
||||
primeReplayToStage,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user