feat: expose retryable presentation target health
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user