feat: expose retryable presentation target health

This commit is contained in:
lda
2026-07-12 10:47:51 +07:00 Verified
parent 1189c21ef7
commit ad8c011351
6 changed files with 117 additions and 20 deletions
@@ -1,5 +1,6 @@
import type { DemoEvent } from "../demo/timeline/models.js";
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
import {
demoBeatLensForBeat,
graphExecutionForBeat,
@@ -18,6 +19,7 @@ import { OperationBlock } from "./OperationBlock.js";
import { RunInputFacts } from "./RunFactsPanel.js";
import { StageCaption } from "./StageCaption.js";
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
type DemoWorkflowSceneProps = {
@@ -28,6 +30,10 @@ type DemoWorkflowSceneProps = {
readonly selectNode: (nodeId: string | null) => void;
readonly openEvidence: () => void;
readonly approvalActions?: DemoApprovalActions | undefined;
readonly timelineAgent?: TimelineAgentController | undefined;
readonly targetStatus?: PresentationTargetHealth | undefined;
readonly retryHealth?: (() => void) | undefined;
readonly liveTargetReady?: boolean | undefined;
};
type DemoWorkflowLayout = "operation" | "graph" | "interrupt" | "approval" | "evidence";
@@ -59,6 +65,10 @@ export const DemoWorkflowScene = ({
selectNode,
openEvidence,
approvalActions,
timelineAgent,
targetStatus,
retryHealth,
liveTargetReady,
}: DemoWorkflowSceneProps) => {
const runStart = findEvent(demo, "run_start");
const runResume = findEvent(demo, "run_resume");
@@ -54,7 +54,8 @@ export const PresentationRoute = () => {
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
const isScene8 = state.location.kind === "main" && state.location.sceneId === "agent-handoff";
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state, !isScene8);
const targetStatusController = usePresentationTargetStatus(presentationTarget, demo.state, !isScene8);
const targetStatus = targetStatusController.status;
const timelineAgent = useTimelineAgent(demo, {
mode: presentationTarget.mode === "live" ? "live" : "replay",
status: targetStatus,
@@ -261,6 +262,8 @@ export const PresentationRoute = () => {
timelineAgent={timelineAgent}
approvalActions={approvalActions}
targetStatus={targetStatus}
retryHealth={targetStatusController.retryHealth}
liveTargetReady={targetStatusController.liveTargetReady}
jump={handleJump}
onScene9Advance={handleScene9Advance}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
@@ -24,6 +24,8 @@ type PresentationStageProps = {
readonly onApprove?: (() => void) | undefined;
readonly onRequestRevision?: (() => void) | undefined;
readonly targetStatus: PresentationTargetHealth;
readonly retryHealth: () => void;
readonly liveTargetReady: boolean;
readonly jump: (location: MainLocation) => void;
readonly onScene9Advance?: (() => void) | undefined;
readonly selectNode: (nodeId: string | null) => void;
@@ -43,6 +45,8 @@ export const PresentationStage = ({
onApprove,
onRequestRevision,
targetStatus,
retryHealth,
liveTargetReady,
jump,
onScene9Advance,
selectNode,
@@ -89,6 +93,9 @@ export const PresentationStage = ({
}}
motionDisabled={state.motionDisabled}
approvalActions={approvalActions}
targetStatus={targetStatus}
retryHealth={retryHealth}
liveTargetReady={liveTargetReady}
onScene9Advance={onScene9Advance}
/>
)}
@@ -22,6 +22,7 @@ import { projectPreparedAuthoringPhase } from "./authoring/authoring-projection.
import type { AuthoringPhaseId } from "./authoring/authoring-recording.js";
import { OpeningThesisScene } from "./opening/OpeningThesisScene.js";
import { ProblemLoopScene } from "./opening/ProblemLoopScene.js";
import type { PresentationTargetHealth } from "./presentation-target-status.js";
type SceneBodyProps = {
readonly location: PresentationLocation;
@@ -34,6 +35,9 @@ type SceneBodyProps = {
readonly onFocusPathChange: (path: readonly string[]) => void;
readonly motionDisabled: boolean;
readonly approvalActions?: DemoApprovalActions | undefined;
readonly targetStatus?: PresentationTargetHealth | undefined;
readonly retryHealth?: (() => void) | undefined;
readonly liveTargetReady?: boolean | undefined;
readonly onScene9Advance?: (() => void) | undefined;
};
@@ -305,7 +309,7 @@ const assertNever = (value: never): never => {
throw new Error(`Unexpected view: ${value}`);
};
export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selectNode, openEvidence, openDiscussion, onFocusPathChange, motionDisabled, approvalActions, onScene9Advance }: SceneBodyProps) => {
export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selectNode, openEvidence, openDiscussion, onFocusPathChange, motionDisabled, approvalActions, targetStatus, retryHealth, liveTargetReady, onScene9Advance }: SceneBodyProps) => {
const sceneId = location.kind === "main" ? location.sceneId : "positioning";
const beatId = location.kind === "main" ? location.beatId : "landscape";
const scene = findScene(sceneId) ?? findScene("thesis")!;
@@ -354,6 +358,10 @@ export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selec
selectNode={selectNode}
openEvidence={openEvidence}
approvalActions={approvalActions}
timelineAgent={timelineAgent}
targetStatus={targetStatus}
retryHealth={retryHealth}
liveTargetReady={liveTargetReady}
/>
);
case "evaluation":
@@ -6,6 +6,12 @@ import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
vi.mock("../connection/api.js", () => ({ callOperation: vi.fn() }));
const mockedCallOperation = vi.mocked(callOperation);
const target = { mode: "live" as const, target: "http://127.0.0.1:8765/rpc", source: "default" as const };
const replayState = {
...initialDemoTimelineState,
mode: "replay" as const,
phase: "paused" as const,
};
beforeEach(() => mockedCallOperation.mockReset());
@@ -23,12 +29,13 @@ describe("usePresentationTargetStatus", () => {
const { result } = renderHook(() =>
usePresentationTargetStatus(
{ mode: "live", target: "http://127.0.0.1:8765/rpc", source: "default" },
target,
initialDemoTimelineState,
),
);
await waitFor(() => expect(result.current.kind).toBe("ready"));
await waitFor(() => expect(result.current.status.kind).toBe("ready"));
expect(result.current.liveTargetReady).toBe(true);
});
it("falls back to replay when health fails", async () => {
@@ -40,12 +47,60 @@ describe("usePresentationTargetStatus", () => {
const { result } = renderHook(() =>
usePresentationTargetStatus(
{ mode: "live", target: "http://127.0.0.1:8765/rpc", source: "default" },
target,
initialDemoTimelineState,
),
);
await waitFor(() => expect(result.current.kind).toBe("failed"));
expect(result.current.label).toBe("Replay fallback");
await waitFor(() => expect(result.current.status.kind).toBe("failed"));
expect(result.current.status.label).toBe("Replay fallback");
expect(result.current.liveTargetReady).toBe(false);
});
});
it("keeps live target readiness visible while direct replay is active", async () => {
mockedCallOperation.mockResolvedValue({
ok: true as const,
operation: "workflow.health",
label: "Health",
interpreted: { status: "ok", storeRoot: "store" },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
durationMs: 2,
});
const { result } = renderHook(() =>
usePresentationTargetStatus(target, replayState),
);
await waitFor(() => expect(result.current.status.kind).toBe("replay"));
expect(result.current.liveTargetReady).toBe(true);
});
it("retries health without changing replay playback", async () => {
mockedCallOperation
.mockResolvedValueOnce({
ok: false as const,
error: { code: "upstream_unreachable", message: "connection refused" },
exchange: { request: {}, response: {} },
})
.mockResolvedValueOnce({
ok: true as const,
operation: "workflow.health",
label: "Health",
interpreted: { status: "ok", storeRoot: "store" },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf status",
durationMs: 2,
});
const { result } = renderHook(() =>
usePresentationTargetStatus(target, replayState),
);
await waitFor(() => expect(result.current.status.kind).toBe("failed"));
act(() => result.current.retryHealth());
await waitFor(() => expect(result.current.liveTargetReady).toBe(true));
expect(result.current.status.kind).toBe("replay");
expect(mockedCallOperation).toHaveBeenCalledTimes(2);
});
});
@@ -8,6 +8,12 @@ import {
type TargetProbeState,
} from "./presentation-target-status.js";
export type PresentationTargetStatusController = {
readonly status: PresentationTargetHealth;
readonly retryHealth: () => void;
readonly liveTargetReady: boolean;
};
const liveActive = (state: DemoTimelineState): boolean =>
state.mode === "live" && state.phase !== "ready";
@@ -15,11 +21,12 @@ export const usePresentationTargetStatus = (
targetState: PresentationTargetState,
demoState: DemoTimelineState,
probeEnabled = true,
): PresentationTargetHealth => {
): PresentationTargetStatusController => {
const [probe, setProbe] = useState<TargetProbeState>(
targetState.mode === "live" ? "checking" : "none",
);
const [failureReason, setFailureReason] = useState<string | undefined>(undefined);
const [probeGeneration, setProbeGeneration] = useState(0);
useEffect(() => {
let cancelled = false;
@@ -56,23 +63,30 @@ export const usePresentationTargetStatus = (
return () => {
cancelled = true;
};
}, [probeEnabled, targetState]);
}, [probeEnabled, probeGeneration, targetState]);
if (!probeEnabled) {
return presentationTargetHealth({
const status = !probeEnabled
? presentationTargetHealth({
target: null,
probe: "none",
liveActive: false,
replayActive: true,
failureReason: "deterministic Scene 8 replay",
})
: presentationTargetHealth({
target: targetState.mode === "live" ? targetState.target : null,
probe,
liveActive: liveActive(demoState),
replayActive: demoState.mode === "replay",
failureReason,
});
}
return presentationTargetHealth({
target: targetState.mode === "live" ? targetState.target : null,
probe,
liveActive: liveActive(demoState),
replayActive: demoState.mode === "replay",
failureReason,
});
const retryHealth = () => setProbeGeneration((generation) => generation + 1);
const liveTargetReady = targetState.mode === "live" && probe === "ready";
return {
status,
retryHealth,
liveTargetReady,
};
};