feat: prime replay timeline for demo beats
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user