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 { DemoEvent } from "../demo/timeline/models.js";
|
||||||
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
import type { DemoTimelineController } from "../demo/useDemoTimeline.js";
|
||||||
|
import type { TimelineAgentController } from "../demo/agent/timelineAgent.js";
|
||||||
import {
|
import {
|
||||||
demoBeatLensForBeat,
|
demoBeatLensForBeat,
|
||||||
graphExecutionForBeat,
|
graphExecutionForBeat,
|
||||||
@@ -18,6 +19,7 @@ import { OperationBlock } from "./OperationBlock.js";
|
|||||||
import { RunInputFacts } from "./RunFactsPanel.js";
|
import { RunInputFacts } from "./RunFactsPanel.js";
|
||||||
import { StageCaption } from "./StageCaption.js";
|
import { StageCaption } from "./StageCaption.js";
|
||||||
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
|
import type { SceneBeatDefinition, SceneDefinition } from "./storyboard.js";
|
||||||
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
import { WorkflowGraphStage } from "./WorkflowGraphStage.js";
|
||||||
|
|
||||||
type DemoWorkflowSceneProps = {
|
type DemoWorkflowSceneProps = {
|
||||||
@@ -28,6 +30,10 @@ type DemoWorkflowSceneProps = {
|
|||||||
readonly selectNode: (nodeId: string | null) => void;
|
readonly selectNode: (nodeId: string | null) => void;
|
||||||
readonly openEvidence: () => void;
|
readonly openEvidence: () => void;
|
||||||
readonly approvalActions?: DemoApprovalActions | undefined;
|
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";
|
type DemoWorkflowLayout = "operation" | "graph" | "interrupt" | "approval" | "evidence";
|
||||||
@@ -59,6 +65,10 @@ export const DemoWorkflowScene = ({
|
|||||||
selectNode,
|
selectNode,
|
||||||
openEvidence,
|
openEvidence,
|
||||||
approvalActions,
|
approvalActions,
|
||||||
|
timelineAgent,
|
||||||
|
targetStatus,
|
||||||
|
retryHealth,
|
||||||
|
liveTargetReady,
|
||||||
}: DemoWorkflowSceneProps) => {
|
}: DemoWorkflowSceneProps) => {
|
||||||
const runStart = findEvent(demo, "run_start");
|
const runStart = findEvent(demo, "run_start");
|
||||||
const runResume = findEvent(demo, "run_resume");
|
const runResume = findEvent(demo, "run_resume");
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export const PresentationRoute = () => {
|
|||||||
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
||||||
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
||||||
const isScene8 = state.location.kind === "main" && state.location.sceneId === "agent-handoff";
|
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, {
|
const timelineAgent = useTimelineAgent(demo, {
|
||||||
mode: presentationTarget.mode === "live" ? "live" : "replay",
|
mode: presentationTarget.mode === "live" ? "live" : "replay",
|
||||||
status: targetStatus,
|
status: targetStatus,
|
||||||
@@ -261,6 +262,8 @@ export const PresentationRoute = () => {
|
|||||||
timelineAgent={timelineAgent}
|
timelineAgent={timelineAgent}
|
||||||
approvalActions={approvalActions}
|
approvalActions={approvalActions}
|
||||||
targetStatus={targetStatus}
|
targetStatus={targetStatus}
|
||||||
|
retryHealth={targetStatusController.retryHealth}
|
||||||
|
liveTargetReady={targetStatusController.liveTargetReady}
|
||||||
jump={handleJump}
|
jump={handleJump}
|
||||||
onScene9Advance={handleScene9Advance}
|
onScene9Advance={handleScene9Advance}
|
||||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type PresentationStageProps = {
|
|||||||
readonly onApprove?: (() => void) | undefined;
|
readonly onApprove?: (() => void) | undefined;
|
||||||
readonly onRequestRevision?: (() => void) | undefined;
|
readonly onRequestRevision?: (() => void) | undefined;
|
||||||
readonly targetStatus: PresentationTargetHealth;
|
readonly targetStatus: PresentationTargetHealth;
|
||||||
|
readonly retryHealth: () => void;
|
||||||
|
readonly liveTargetReady: boolean;
|
||||||
readonly jump: (location: MainLocation) => void;
|
readonly jump: (location: MainLocation) => void;
|
||||||
readonly onScene9Advance?: (() => void) | undefined;
|
readonly onScene9Advance?: (() => void) | undefined;
|
||||||
readonly selectNode: (nodeId: string | null) => void;
|
readonly selectNode: (nodeId: string | null) => void;
|
||||||
@@ -43,6 +45,8 @@ export const PresentationStage = ({
|
|||||||
onApprove,
|
onApprove,
|
||||||
onRequestRevision,
|
onRequestRevision,
|
||||||
targetStatus,
|
targetStatus,
|
||||||
|
retryHealth,
|
||||||
|
liveTargetReady,
|
||||||
jump,
|
jump,
|
||||||
onScene9Advance,
|
onScene9Advance,
|
||||||
selectNode,
|
selectNode,
|
||||||
@@ -89,6 +93,9 @@ export const PresentationStage = ({
|
|||||||
}}
|
}}
|
||||||
motionDisabled={state.motionDisabled}
|
motionDisabled={state.motionDisabled}
|
||||||
approvalActions={approvalActions}
|
approvalActions={approvalActions}
|
||||||
|
targetStatus={targetStatus}
|
||||||
|
retryHealth={retryHealth}
|
||||||
|
liveTargetReady={liveTargetReady}
|
||||||
onScene9Advance={onScene9Advance}
|
onScene9Advance={onScene9Advance}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { projectPreparedAuthoringPhase } from "./authoring/authoring-projection.
|
|||||||
import type { AuthoringPhaseId } from "./authoring/authoring-recording.js";
|
import type { AuthoringPhaseId } from "./authoring/authoring-recording.js";
|
||||||
import { OpeningThesisScene } from "./opening/OpeningThesisScene.js";
|
import { OpeningThesisScene } from "./opening/OpeningThesisScene.js";
|
||||||
import { ProblemLoopScene } from "./opening/ProblemLoopScene.js";
|
import { ProblemLoopScene } from "./opening/ProblemLoopScene.js";
|
||||||
|
import type { PresentationTargetHealth } from "./presentation-target-status.js";
|
||||||
|
|
||||||
type SceneBodyProps = {
|
type SceneBodyProps = {
|
||||||
readonly location: PresentationLocation;
|
readonly location: PresentationLocation;
|
||||||
@@ -34,6 +35,9 @@ type SceneBodyProps = {
|
|||||||
readonly onFocusPathChange: (path: readonly string[]) => void;
|
readonly onFocusPathChange: (path: readonly string[]) => void;
|
||||||
readonly motionDisabled: boolean;
|
readonly motionDisabled: boolean;
|
||||||
readonly approvalActions?: DemoApprovalActions | undefined;
|
readonly approvalActions?: DemoApprovalActions | undefined;
|
||||||
|
readonly targetStatus?: PresentationTargetHealth | undefined;
|
||||||
|
readonly retryHealth?: (() => void) | undefined;
|
||||||
|
readonly liveTargetReady?: boolean | undefined;
|
||||||
readonly onScene9Advance?: (() => void) | undefined;
|
readonly onScene9Advance?: (() => void) | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,7 +309,7 @@ const assertNever = (value: never): never => {
|
|||||||
throw new Error(`Unexpected view: ${value}`);
|
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 sceneId = location.kind === "main" ? location.sceneId : "positioning";
|
||||||
const beatId = location.kind === "main" ? location.beatId : "landscape";
|
const beatId = location.kind === "main" ? location.beatId : "landscape";
|
||||||
const scene = findScene(sceneId) ?? findScene("thesis")!;
|
const scene = findScene(sceneId) ?? findScene("thesis")!;
|
||||||
@@ -354,6 +358,10 @@ export const SceneBody = ({ location, demo, timelineAgent, selectedNodeId, selec
|
|||||||
selectNode={selectNode}
|
selectNode={selectNode}
|
||||||
openEvidence={openEvidence}
|
openEvidence={openEvidence}
|
||||||
approvalActions={approvalActions}
|
approvalActions={approvalActions}
|
||||||
|
timelineAgent={timelineAgent}
|
||||||
|
targetStatus={targetStatus}
|
||||||
|
retryHealth={retryHealth}
|
||||||
|
liveTargetReady={liveTargetReady}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "evaluation":
|
case "evaluation":
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { usePresentationTargetStatus } from "./usePresentationTargetStatus.js";
|
|||||||
|
|
||||||
vi.mock("../connection/api.js", () => ({ callOperation: vi.fn() }));
|
vi.mock("../connection/api.js", () => ({ callOperation: vi.fn() }));
|
||||||
const mockedCallOperation = vi.mocked(callOperation);
|
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());
|
beforeEach(() => mockedCallOperation.mockReset());
|
||||||
|
|
||||||
@@ -23,12 +29,13 @@ describe("usePresentationTargetStatus", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
usePresentationTargetStatus(
|
usePresentationTargetStatus(
|
||||||
{ mode: "live", target: "http://127.0.0.1:8765/rpc", source: "default" },
|
target,
|
||||||
initialDemoTimelineState,
|
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 () => {
|
it("falls back to replay when health fails", async () => {
|
||||||
@@ -40,12 +47,60 @@ describe("usePresentationTargetStatus", () => {
|
|||||||
|
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
usePresentationTargetStatus(
|
usePresentationTargetStatus(
|
||||||
{ mode: "live", target: "http://127.0.0.1:8765/rpc", source: "default" },
|
target,
|
||||||
initialDemoTimelineState,
|
initialDemoTimelineState,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.kind).toBe("failed"));
|
await waitFor(() => expect(result.current.status.kind).toBe("failed"));
|
||||||
expect(result.current.label).toBe("Replay fallback");
|
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,
|
type TargetProbeState,
|
||||||
} from "./presentation-target-status.js";
|
} from "./presentation-target-status.js";
|
||||||
|
|
||||||
|
export type PresentationTargetStatusController = {
|
||||||
|
readonly status: PresentationTargetHealth;
|
||||||
|
readonly retryHealth: () => void;
|
||||||
|
readonly liveTargetReady: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const liveActive = (state: DemoTimelineState): boolean =>
|
const liveActive = (state: DemoTimelineState): boolean =>
|
||||||
state.mode === "live" && state.phase !== "ready";
|
state.mode === "live" && state.phase !== "ready";
|
||||||
|
|
||||||
@@ -15,11 +21,12 @@ export const usePresentationTargetStatus = (
|
|||||||
targetState: PresentationTargetState,
|
targetState: PresentationTargetState,
|
||||||
demoState: DemoTimelineState,
|
demoState: DemoTimelineState,
|
||||||
probeEnabled = true,
|
probeEnabled = true,
|
||||||
): PresentationTargetHealth => {
|
): PresentationTargetStatusController => {
|
||||||
const [probe, setProbe] = useState<TargetProbeState>(
|
const [probe, setProbe] = useState<TargetProbeState>(
|
||||||
targetState.mode === "live" ? "checking" : "none",
|
targetState.mode === "live" ? "checking" : "none",
|
||||||
);
|
);
|
||||||
const [failureReason, setFailureReason] = useState<string | undefined>(undefined);
|
const [failureReason, setFailureReason] = useState<string | undefined>(undefined);
|
||||||
|
const [probeGeneration, setProbeGeneration] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -56,23 +63,30 @@ export const usePresentationTargetStatus = (
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [probeEnabled, targetState]);
|
}, [probeEnabled, probeGeneration, targetState]);
|
||||||
|
|
||||||
if (!probeEnabled) {
|
const status = !probeEnabled
|
||||||
return presentationTargetHealth({
|
? presentationTargetHealth({
|
||||||
target: null,
|
target: null,
|
||||||
probe: "none",
|
probe: "none",
|
||||||
liveActive: false,
|
liveActive: false,
|
||||||
replayActive: true,
|
replayActive: true,
|
||||||
failureReason: "deterministic Scene 8 replay",
|
failureReason: "deterministic Scene 8 replay",
|
||||||
|
})
|
||||||
|
: presentationTargetHealth({
|
||||||
|
target: targetState.mode === "live" ? targetState.target : null,
|
||||||
|
probe,
|
||||||
|
liveActive: liveActive(demoState),
|
||||||
|
replayActive: demoState.mode === "replay",
|
||||||
|
failureReason,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return presentationTargetHealth({
|
const retryHealth = () => setProbeGeneration((generation) => generation + 1);
|
||||||
target: targetState.mode === "live" ? targetState.target : null,
|
const liveTargetReady = targetState.mode === "live" && probe === "ready";
|
||||||
probe,
|
|
||||||
liveActive: liveActive(demoState),
|
return {
|
||||||
replayActive: demoState.mode === "replay",
|
status,
|
||||||
failureReason,
|
retryHealth,
|
||||||
});
|
liveTargetReady,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user