fix: address console review findings
This commit is contained in:
@@ -169,7 +169,7 @@ export const ConsoleHome = () => {
|
|||||||
loading={state.sourcesLoading}
|
loading={state.sourcesLoading}
|
||||||
error={state.sourceError}
|
error={state.sourceError}
|
||||||
/>
|
/>
|
||||||
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer">
|
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer" data-panel="lifecycle-explorer">
|
||||||
<LifecycleExplorer controller={lifecycleController} />
|
<LifecycleExplorer controller={lifecycleController} />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type Props = {
|
|||||||
|
|
||||||
export const SourceInventory = ({ sources, loading, error }: Props) => {
|
export const SourceInventory = ({ sources, loading, error }: Props) => {
|
||||||
return (
|
return (
|
||||||
<section aria-label="Source Inventory">
|
<section aria-label="Source Inventory" data-panel="source-inventory">
|
||||||
<h2>Sources</h2>
|
<h2>Sources</h2>
|
||||||
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
|
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
|
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
|
||||||
import { DemoTimelineControls } from "./DemoTimelineControls.js";
|
import { DemoTimelineControls } from "./DemoTimelineControls.js";
|
||||||
import { DemoTimeline } from "./DemoTimeline.js";
|
import { DemoTimeline } from "./DemoTimeline.js";
|
||||||
import type { DemoTimelineController } from "./useDemoTimeline.js";
|
import type { DemoTimelineController } from "./useDemoTimeline.js";
|
||||||
|
|
||||||
|
const DEFAULT_REVIEW_COMMENT = "Create selected issues before the defense.";
|
||||||
|
|
||||||
export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => {
|
export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => {
|
||||||
const { state } = controller;
|
const { state } = controller;
|
||||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
const [comment, setComment] = useState("Create selected issues before the defense.");
|
const [comment, setComment] = useState(DEFAULT_REVIEW_COMMENT);
|
||||||
|
|
||||||
const proposedIssues = controller.interruptPayload?.proposed_issues ?? [];
|
const proposedIssues = controller.interruptPayload?.proposed_issues ?? [];
|
||||||
const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
|
const selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setComment(DEFAULT_REVIEW_COMMENT);
|
||||||
|
}, [state.mode, state.phase, controller.interruptPayload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="lda report workflow demo" className="demo-panel">
|
<section aria-label="lda report workflow demo" className="demo-panel">
|
||||||
<div className="demo-panel__header">
|
<div className="demo-panel__header">
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ export type LiveStepResult = {
|
|||||||
readonly events: ReadonlyArray<DemoEvent>;
|
readonly events: ReadonlyArray<DemoEvent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseDemoEvent = (
|
||||||
|
context: LiveDemoContext,
|
||||||
|
stage: DemoEventStage,
|
||||||
|
runId: string | null,
|
||||||
|
sequenceOffset = 0,
|
||||||
|
): Pick<DemoEvent, "id" | "sequence" | "stage" | "resultingIds" | "recordedAt"> => {
|
||||||
|
const sequence = context.nextSequence + sequenceOffset;
|
||||||
|
return {
|
||||||
|
id: `live-${sequence}-${stage}-${runId ?? "pending"}`,
|
||||||
|
sequence,
|
||||||
|
stage,
|
||||||
|
resultingIds: {
|
||||||
|
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
||||||
|
runId,
|
||||||
|
},
|
||||||
|
recordedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const eventFromResult = (
|
const eventFromResult = (
|
||||||
context: LiveDemoContext,
|
context: LiveDemoContext,
|
||||||
stage: DemoEventStage,
|
stage: DemoEventStage,
|
||||||
@@ -60,9 +79,7 @@ const eventFromResult = (
|
|||||||
result: RpcResponse,
|
result: RpcResponse,
|
||||||
runId: string | null,
|
runId: string | null,
|
||||||
): DemoEvent => ({
|
): DemoEvent => ({
|
||||||
id: `live-${context.nextSequence}-${stage}-${runId ?? "pending"}`,
|
...baseDemoEvent(context, stage, runId),
|
||||||
sequence: context.nextSequence,
|
|
||||||
stage,
|
|
||||||
operation,
|
operation,
|
||||||
reason: result.ok ? reason : result.error.message,
|
reason: result.ok ? reason : result.error.message,
|
||||||
equivalentCli: result.ok ? result.equivalentCli : null,
|
equivalentCli: result.ok ? result.equivalentCli : null,
|
||||||
@@ -70,11 +87,6 @@ const eventFromResult = (
|
|||||||
rawResponse: result.exchange.response,
|
rawResponse: result.exchange.response,
|
||||||
interpreted: result.ok ? result.interpreted : null,
|
interpreted: result.ok ? result.interpreted : null,
|
||||||
durationMs: result.ok ? result.durationMs : 0,
|
durationMs: result.ok ? result.durationMs : 0,
|
||||||
resultingIds: {
|
|
||||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
|
||||||
runId,
|
|
||||||
},
|
|
||||||
recordedAt: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const syntheticEvent = (
|
const syntheticEvent = (
|
||||||
@@ -85,9 +97,7 @@ const syntheticEvent = (
|
|||||||
runId: string | null,
|
runId: string | null,
|
||||||
sequenceOffset = 0,
|
sequenceOffset = 0,
|
||||||
): DemoEvent => ({
|
): DemoEvent => ({
|
||||||
id: `live-${context.nextSequence + sequenceOffset}-${stage}-${runId ?? "pending"}`,
|
...baseDemoEvent(context, stage, runId, sequenceOffset),
|
||||||
sequence: context.nextSequence + sequenceOffset,
|
|
||||||
stage,
|
|
||||||
operation: null,
|
operation: null,
|
||||||
reason,
|
reason,
|
||||||
equivalentCli: null,
|
equivalentCli: null,
|
||||||
@@ -95,8 +105,6 @@ const syntheticEvent = (
|
|||||||
rawResponse: null,
|
rawResponse: null,
|
||||||
interpreted,
|
interpreted,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
resultingIds: { deploymentId: LDA_REPORT_DEPLOYMENT_ID, runId },
|
|
||||||
recordedAt: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const operationForStage = (stage: LiveDemoStage): string | null => {
|
const operationForStage = (stage: LiveDemoStage): string | null => {
|
||||||
@@ -118,9 +126,7 @@ export const failedLiveDemoEvent = (
|
|||||||
context: LiveDemoContext,
|
context: LiveDemoContext,
|
||||||
reason: string,
|
reason: string,
|
||||||
): DemoEvent => ({
|
): DemoEvent => ({
|
||||||
id: `live-${context.nextSequence}-failed-${context.runId ?? "pending"}`,
|
...baseDemoEvent(context, "failed", context.runId),
|
||||||
sequence: context.nextSequence,
|
|
||||||
stage: "failed",
|
|
||||||
operation: operationForStage(context.nextStage),
|
operation: operationForStage(context.nextStage),
|
||||||
reason,
|
reason,
|
||||||
equivalentCli: null,
|
equivalentCli: null,
|
||||||
@@ -128,11 +134,6 @@ export const failedLiveDemoEvent = (
|
|||||||
rawResponse: null,
|
rawResponse: null,
|
||||||
interpreted: null,
|
interpreted: null,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
resultingIds: {
|
|
||||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
|
||||||
runId: context.runId,
|
|
||||||
},
|
|
||||||
recordedAt: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const executeLiveDemoStep = async (
|
export const executeLiveDemoStep = async (
|
||||||
@@ -238,6 +239,19 @@ export const executeLiveDemoStep = async (
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const detail = decodeRunDetail(result.interpreted);
|
const detail = decodeRunDetail(result.interpreted);
|
||||||
|
if (detail.status !== "completed") {
|
||||||
|
const failed = syntheticEvent(
|
||||||
|
context,
|
||||||
|
"failed",
|
||||||
|
`Demo resume returned ${detail.status} instead of completed.`,
|
||||||
|
result.interpreted,
|
||||||
|
detail.runId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
events: [failed],
|
||||||
|
context: { ...context, nextStage: "done", runId: detail.runId, nextSequence: context.nextSequence + 1 },
|
||||||
|
};
|
||||||
|
}
|
||||||
const output = parseLdaReportOutput(detail.output);
|
const output = parseLdaReportOutput(detail.output);
|
||||||
return {
|
return {
|
||||||
events: [eventFromResult(context, "run_resume", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
|
events: [eventFromResult(context, "run_resume", "workflow.runs.resume", "Resume the interrupted run.", params, result, context.runId)],
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const useDemoTimeline = (
|
|||||||
const recordEvidenceRef = useRef(recordEvidence);
|
const recordEvidenceRef = useRef(recordEvidence);
|
||||||
recordEvidenceRef.current = recordEvidence;
|
recordEvidenceRef.current = recordEvidence;
|
||||||
const inFlightRef = useRef(false);
|
const inFlightRef = useRef(false);
|
||||||
|
const generationRef = useRef(0);
|
||||||
const [inFlight, setInFlight] = useState(false);
|
const [inFlight, setInFlight] = useState(false);
|
||||||
const approvalRef = useRef<DemoApproval | null>(null);
|
const approvalRef = useRef<DemoApproval | null>(null);
|
||||||
const activeRecording = useRef(loadCanonicalDemoRecording());
|
const activeRecording = useRef(loadCanonicalDemoRecording());
|
||||||
@@ -71,9 +72,25 @@ export const useDemoTimeline = (
|
|||||||
const [output, setOutput] = useState<LdaReportOutput | null>(null);
|
const [output, setOutput] = useState<LdaReportOutput | null>(null);
|
||||||
const [trace, setTrace] = useState<TracePage | null>(null);
|
const [trace, setTrace] = useState<TracePage | null>(null);
|
||||||
|
|
||||||
|
const resetRuntime = useCallback(() => {
|
||||||
|
generationRef.current++;
|
||||||
|
inFlightRef.current = false;
|
||||||
|
setInFlight(false);
|
||||||
|
liveContextRef.current = initialLiveDemoContext;
|
||||||
|
approvalRef.current = null;
|
||||||
|
setInterruptPayload(null);
|
||||||
|
setOutput(null);
|
||||||
|
setTrace(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetRuntime();
|
||||||
|
}, [target, resetRuntime]);
|
||||||
|
|
||||||
const step = useCallback(async () => {
|
const step = useCallback(async () => {
|
||||||
if (inFlightRef.current) return;
|
if (inFlightRef.current) return;
|
||||||
if (state.appliedCount >= state.events.length && state.mode === "replay") return;
|
if (state.appliedCount >= state.events.length && state.mode === "replay") return;
|
||||||
|
const generation = generationRef.current;
|
||||||
inFlightRef.current = true;
|
inFlightRef.current = true;
|
||||||
setInFlight(true);
|
setInFlight(true);
|
||||||
try {
|
try {
|
||||||
@@ -102,6 +119,7 @@ export const useDemoTimeline = (
|
|||||||
const approval = approvalRef.current;
|
const approval = approvalRef.current;
|
||||||
approvalRef.current = null;
|
approvalRef.current = null;
|
||||||
const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined);
|
const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined);
|
||||||
|
if (generation !== generationRef.current) return;
|
||||||
liveContextRef.current = result.context;
|
liveContextRef.current = result.context;
|
||||||
for (const event of result.events) {
|
for (const event of result.events) {
|
||||||
dispatch({ type: "append_live_event", event });
|
dispatch({ type: "append_live_event", event });
|
||||||
@@ -162,8 +180,10 @@ export const useDemoTimeline = (
|
|||||||
dispatch({ type: "fail", message });
|
dispatch({ type: "fail", message });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
inFlightRef.current = false;
|
if (generation === generationRef.current) {
|
||||||
setInFlight(false);
|
inFlightRef.current = false;
|
||||||
|
setInFlight(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [state.mode, state.appliedCount, state.events, target]);
|
}, [state.mode, state.appliedCount, state.events, target]);
|
||||||
|
|
||||||
@@ -177,25 +197,19 @@ export const useDemoTimeline = (
|
|||||||
}, [state.phase, state.autoplay, state.appliedCount, step]);
|
}, [state.phase, state.autoplay, state.appliedCount, step]);
|
||||||
|
|
||||||
const setMode = useCallback((mode: DemoMode) => {
|
const setMode = useCallback((mode: DemoMode) => {
|
||||||
|
resetRuntime();
|
||||||
dispatch({ type: "set_mode", mode });
|
dispatch({ type: "set_mode", mode });
|
||||||
liveContextRef.current = initialLiveDemoContext;
|
}, [resetRuntime]);
|
||||||
setInterruptPayload(null);
|
|
||||||
setOutput(null);
|
|
||||||
setTrace(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback(() => {
|
||||||
|
resetRuntime();
|
||||||
if (state.mode === "replay") {
|
if (state.mode === "replay") {
|
||||||
const recording = activeRecording.current;
|
const recording = activeRecording.current;
|
||||||
dispatch({ type: "start", mode: "replay", events: recording.events });
|
dispatch({ type: "start", mode: "replay", events: recording.events });
|
||||||
} else {
|
} else {
|
||||||
liveContextRef.current = initialLiveDemoContext;
|
|
||||||
dispatch({ type: "start", mode: "live", events: [] });
|
dispatch({ type: "start", mode: "live", events: [] });
|
||||||
}
|
}
|
||||||
setInterruptPayload(null);
|
}, [resetRuntime, state.mode]);
|
||||||
setOutput(null);
|
|
||||||
setTrace(null);
|
|
||||||
}, [state.mode]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => dispatch({ type: "pause" }), []);
|
const pause = useCallback(() => dispatch({ type: "pause" }), []);
|
||||||
const play = useCallback(() => dispatch({ type: "play" }), []);
|
const play = useCallback(() => dispatch({ type: "play" }), []);
|
||||||
@@ -229,12 +243,9 @@ export const useDemoTimeline = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const restart = useCallback(() => {
|
const restart = useCallback(() => {
|
||||||
|
resetRuntime();
|
||||||
dispatch({ type: "restart" });
|
dispatch({ type: "restart" });
|
||||||
liveContextRef.current = initialLiveDemoContext;
|
}, [resetRuntime]);
|
||||||
setInterruptPayload(null);
|
|
||||||
setOutput(null);
|
|
||||||
setTrace(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ describe("WorkflowGraph", () => {
|
|||||||
expect(onSelect).toHaveBeenCalledWith("review");
|
expect(onSelect).toHaveBeenCalledWith("review");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls onNodeSelect when a focused node is activated by keyboard", () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const { container } = render(<WorkflowGraph model={mockModel} onNodeSelect={onSelect} />);
|
||||||
|
const reviewNode = findNodeById(container, "review");
|
||||||
|
|
||||||
|
fireEvent.keyDown(reviewNode!, { key: "Enter" });
|
||||||
|
fireEvent.keyDown(reviewNode!, { key: " " });
|
||||||
|
|
||||||
|
expect(onSelect).toHaveBeenNthCalledWith(1, "review");
|
||||||
|
expect(onSelect).toHaveBeenNthCalledWith(2, "review");
|
||||||
|
});
|
||||||
|
|
||||||
it("highlights active node when activeNodeId is provided", () => {
|
it("highlights active node when activeNodeId is provided", () => {
|
||||||
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
|
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
|
||||||
const reviewNode = findNodeById(container, "review");
|
const reviewNode = findNodeById(container, "review");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo, type KeyboardEvent } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -39,10 +39,16 @@ const nodeColor = (data: WorkflowGraphNodeData): string => {
|
|||||||
|
|
||||||
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
|
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
|
||||||
const isActive = (data as WorkflowGraphNodeData & { isActive?: boolean }).isActive;
|
const isActive = (data as WorkflowGraphNodeData & { isActive?: boolean }).isActive;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
data.onSelect?.(data.nodeId);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
data-active={isActive}
|
data-active={isActive}
|
||||||
data-node-id={data.nodeId}
|
data-node-id={data.nodeId}
|
||||||
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
|
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
|
||||||
@@ -69,9 +75,9 @@ export const WorkflowGraph = ({ model, activeNodeId = null, onNodeSelect }: Work
|
|||||||
id: n.id,
|
id: n.id,
|
||||||
type: "custom",
|
type: "custom",
|
||||||
position: n.position,
|
position: n.position,
|
||||||
data: { ...n.data, isActive: activeNodeId === n.id },
|
data: { ...n.data, isActive: activeNodeId === n.id, onSelect: onNodeSelect },
|
||||||
})),
|
})),
|
||||||
[model.nodes, activeNodeId],
|
[model.nodes, activeNodeId, onNodeSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const edges: Edge[] = useMemo(
|
const edges: Edge[] = useMemo(
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ describe("buildWorkflowGraph", () => {
|
|||||||
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page");
|
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels subgraph nodes from the workflow name", () => {
|
||||||
|
const model = buildWorkflowGraph({
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "nested",
|
||||||
|
type: "subgraph",
|
||||||
|
workflow: "workflows.report.review",
|
||||||
|
input: [],
|
||||||
|
output: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.nodes[0]?.data.kind).toBe("subgraph");
|
||||||
|
expect(model.nodes[0]?.data.label).toBe("review");
|
||||||
|
});
|
||||||
|
|
||||||
it("creates edges from plan edges", () => {
|
it("creates edges from plan edges", () => {
|
||||||
const model = buildWorkflowGraph(samplePlan);
|
const model = buildWorkflowGraph(samplePlan);
|
||||||
expect(model.edges.length).toBe(5);
|
expect(model.edges.length).toBe(5);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type WorkflowGraphNodeData = {
|
|||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly nodeRef: string | null;
|
readonly nodeRef: string | null;
|
||||||
readonly raw: Readonly<Record<string, unknown>>;
|
readonly raw: Readonly<Record<string, unknown>>;
|
||||||
|
readonly onSelect?: (nodeId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowGraphNode = {
|
export type WorkflowGraphNode = {
|
||||||
@@ -66,6 +67,14 @@ const buildLabel = (node: Record<string, unknown>): string => {
|
|||||||
if (type === "interrupt") return (node.kind as string) ?? "Interrupt";
|
if (type === "interrupt") return (node.kind as string) ?? "Interrupt";
|
||||||
if (type === "foreach") return "For Each";
|
if (type === "foreach") return "For Each";
|
||||||
if (type === "join") return "Join";
|
if (type === "join") return "Join";
|
||||||
|
if (type === "subgraph") {
|
||||||
|
const workflowRef = node.workflow as string | undefined;
|
||||||
|
if (workflowRef) {
|
||||||
|
const parts = workflowRef.split(".");
|
||||||
|
return parts[parts.length - 1] ?? workflowRef;
|
||||||
|
}
|
||||||
|
return "Subgraph";
|
||||||
|
}
|
||||||
const nodeRef = node.node as string | undefined;
|
const nodeRef = node.node as string | undefined;
|
||||||
if (nodeRef) {
|
if (nodeRef) {
|
||||||
const parts = nodeRef.split(".");
|
const parts = nodeRef.split(".");
|
||||||
|
|||||||
@@ -150,4 +150,55 @@ describe("LifecycleExplorer", () => {
|
|||||||
render(<LifecycleExplorer controller={controller} />);
|
render(<LifecycleExplorer controller={controller} />);
|
||||||
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible();
|
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps previous records visible while showing loading and error status", () => {
|
||||||
|
const controller = createMockController({
|
||||||
|
artifactList: {
|
||||||
|
phase: "loading",
|
||||||
|
previous: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "report@1",
|
||||||
|
artifactId: "report",
|
||||||
|
version: 1,
|
||||||
|
kind: "workflow",
|
||||||
|
displayName: "Report",
|
||||||
|
description: null,
|
||||||
|
outcomes: ["ok"],
|
||||||
|
requiredSources: ["local.report"],
|
||||||
|
diagnosticCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runList: {
|
||||||
|
phase: "error",
|
||||||
|
message: "network down",
|
||||||
|
previous: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
runId: "run_1",
|
||||||
|
deploymentId: "report.default",
|
||||||
|
artifactId: "report",
|
||||||
|
artifactVersion: 1,
|
||||||
|
status: "completed",
|
||||||
|
resumeReadiness: "not_needed",
|
||||||
|
diagnosticCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
nextCursor: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LifecycleExplorer controller={controller} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/loading artifacts/i)).toBeVisible();
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent(/could not load runs: network down/i);
|
||||||
|
expect(screen.getByRole("option", { name: /Report version 1/i })).toBeVisible();
|
||||||
|
expect(screen.getByRole("option", { name: /run_1 completed/i })).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { buildWorkflowGraph } from "../graph/graph-model.js";
|
|||||||
import { WorkflowGraph } from "../graph/WorkflowGraph.js";
|
import { WorkflowGraph } from "../graph/WorkflowGraph.js";
|
||||||
import { buildTraceFrames } from "../execution/trace-model.js";
|
import { buildTraceFrames } from "../execution/trace-model.js";
|
||||||
import { ExecutionView } from "../execution/ExecutionView.js";
|
import { ExecutionView } from "../execution/ExecutionView.js";
|
||||||
|
import type { LoadState } from "./state.js";
|
||||||
|
|
||||||
type LifecycleExplorerProps = {
|
type LifecycleExplorerProps = {
|
||||||
readonly controller: LifecycleExplorerController;
|
readonly controller: LifecycleExplorerController;
|
||||||
@@ -13,18 +14,24 @@ type LifecycleExplorerProps = {
|
|||||||
|
|
||||||
type FocusMode = "lifecycle" | "graph" | "execution" | "raw";
|
type FocusMode = "lifecycle" | "graph" | "execution" | "raw";
|
||||||
|
|
||||||
|
const loadedItems = <T,>(
|
||||||
|
state: LoadState<{ readonly items: ReadonlyArray<T> }>,
|
||||||
|
): ReadonlyArray<T> =>
|
||||||
|
state.phase === "loaded" ? state.value.items : state.phase === "loading" || state.phase === "error" ? state.previous?.items ?? [] : [];
|
||||||
|
|
||||||
|
const listStatus = <T,>(label: string, state: LoadState<T>) => {
|
||||||
|
if (state.phase === "loading") return <p role="status">Loading {label}...</p>;
|
||||||
|
if (state.phase === "error") return <p role="alert">Could not load {label}: {state.message}</p>;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
|
export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
|
||||||
const { state } = controller;
|
const { state } = controller;
|
||||||
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
|
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
|
||||||
|
|
||||||
const artifacts =
|
const artifacts = loadedItems(state.artifactList);
|
||||||
state.artifactList.phase === "loaded" ? state.artifactList.value.items : [];
|
const deployments = loadedItems(state.deploymentList);
|
||||||
const deployments =
|
const runs = loadedItems(state.runList);
|
||||||
state.deploymentList.phase === "loaded"
|
|
||||||
? state.deploymentList.value.items
|
|
||||||
: [];
|
|
||||||
const runs =
|
|
||||||
state.runList.phase === "loaded" ? state.runList.value.items : [];
|
|
||||||
|
|
||||||
const graphModel = useMemo(() => {
|
const graphModel = useMemo(() => {
|
||||||
if (!state.artifactDetail?.plan) return null;
|
if (!state.artifactDetail?.plan) return null;
|
||||||
@@ -74,6 +81,16 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
|
|||||||
|
|
||||||
{focusMode === "lifecycle" && (
|
{focusMode === "lifecycle" && (
|
||||||
<div className="lifecycle-content">
|
<div className="lifecycle-content">
|
||||||
|
<div className="lifecycle-status">
|
||||||
|
{listStatus("artifacts", state.artifactList)}
|
||||||
|
{listStatus("deployments", state.deploymentList)}
|
||||||
|
{listStatus("runs", state.runList)}
|
||||||
|
{state.errors.map((error) => (
|
||||||
|
<p role="alert" key={`${error.operation}-${error.timestamp}`}>
|
||||||
|
{error.operation}: {error.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<RecordColumns
|
<RecordColumns
|
||||||
artifacts={artifacts}
|
artifacts={artifacts}
|
||||||
deployments={deployments}
|
deployments={deployments}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
|
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
|
||||||
|
|
||||||
type RecordColumnsProps = {
|
type RecordColumnsProps = {
|
||||||
readonly artifacts: ArtifactSummary[];
|
readonly artifacts: ReadonlyArray<ArtifactSummary>;
|
||||||
readonly deployments: DeploymentSummary[];
|
readonly deployments: ReadonlyArray<DeploymentSummary>;
|
||||||
readonly runs: RunSummary[];
|
readonly runs: ReadonlyArray<RunSummary>;
|
||||||
readonly selectedArtifactId: string | null;
|
readonly selectedArtifactId: string | null;
|
||||||
readonly selectedDeploymentId: string | null;
|
readonly selectedDeploymentId: string | null;
|
||||||
readonly selectedRunId: string | null;
|
readonly selectedRunId: string | null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useReducer, useEffect, useRef, useCallback } from "react";
|
import { useReducer, useEffect, useRef, useCallback, type MutableRefObject } from "react";
|
||||||
import { callOperation } from "../connection/api.js";
|
import { callOperation } from "../connection/api.js";
|
||||||
import type { OperationName } from "../connection/contracts.js";
|
import type { OperationName } from "../connection/contracts.js";
|
||||||
import {
|
import {
|
||||||
@@ -26,15 +26,7 @@ export type LifecycleExplorerController = {
|
|||||||
|
|
||||||
export const useLifecycleExplorer = (
|
export const useLifecycleExplorer = (
|
||||||
target: string | null,
|
target: string | null,
|
||||||
recordEvidence: (record: {
|
recordEvidence: (record: EvidenceRecord) => void,
|
||||||
id: string;
|
|
||||||
operation: string;
|
|
||||||
label: string;
|
|
||||||
equivalentCli: string;
|
|
||||||
request: unknown;
|
|
||||||
response: unknown;
|
|
||||||
durationMs: number;
|
|
||||||
}) => void,
|
|
||||||
): LifecycleExplorerController => {
|
): LifecycleExplorerController => {
|
||||||
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
|
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
@@ -49,8 +41,9 @@ export const useLifecycleExplorer = (
|
|||||||
operation: OperationName,
|
operation: OperationName,
|
||||||
params: unknown,
|
params: unknown,
|
||||||
generation: number,
|
generation: number,
|
||||||
checkGenerationRef: React.MutableRefObject<number>,
|
checkGenerationRef: MutableRefObject<number>,
|
||||||
onSuccess: (interpreted: unknown) => void,
|
onSuccess: (interpreted: unknown) => void,
|
||||||
|
onFailure?: (message: string) => void,
|
||||||
) => {
|
) => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +76,7 @@ export const useLifecycleExplorer = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
onFailure?.(result.error.message);
|
||||||
const seq = evidenceSeqRef.current++;
|
const seq = evidenceSeqRef.current++;
|
||||||
const record: EvidenceRecord = {
|
const record: EvidenceRecord = {
|
||||||
id: `${operation}-${seq}`,
|
id: `${operation}-${seq}`,
|
||||||
@@ -106,11 +100,14 @@ export const useLifecycleExplorer = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (rpcError) {
|
} catch (rpcError) {
|
||||||
|
if (generation !== checkGenerationRef.current) return;
|
||||||
|
const message = rpcError instanceof Error ? rpcError.message : String(rpcError);
|
||||||
|
onFailure?.(message);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "pushError",
|
type: "pushError",
|
||||||
error: {
|
error: {
|
||||||
operation,
|
operation,
|
||||||
message: rpcError instanceof Error ? rpcError.message : String(rpcError),
|
message,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -122,20 +119,32 @@ export const useLifecycleExplorer = (
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
generationRef.current++;
|
generationRef.current++;
|
||||||
|
artifactGenerationRef.current++;
|
||||||
|
deploymentGenerationRef.current++;
|
||||||
|
runGenerationRef.current++;
|
||||||
const generation = generationRef.current;
|
const generation = generationRef.current;
|
||||||
rawEvidenceRef.current = [];
|
rawEvidenceRef.current = [];
|
||||||
dispatch({ type: "targetChanged" });
|
dispatch({ type: "targetChanged" });
|
||||||
|
dispatch({ type: "setArtifactListPhase", phase: "loading" });
|
||||||
|
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
|
||||||
|
dispatch({ type: "setRunListPhase", phase: "loading" });
|
||||||
|
|
||||||
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||||
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
||||||
|
}, (message) => {
|
||||||
|
dispatch({ type: "setArtifactListPhase", phase: "error", message });
|
||||||
});
|
});
|
||||||
|
|
||||||
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
|
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
|
||||||
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
|
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
|
||||||
|
}, (message) => {
|
||||||
|
dispatch({ type: "setDeploymentListPhase", phase: "error", message });
|
||||||
});
|
});
|
||||||
|
|
||||||
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||||
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
||||||
|
}, (message) => {
|
||||||
|
dispatch({ type: "setRunListPhase", phase: "error", message });
|
||||||
});
|
});
|
||||||
}, [target, executeOperation]);
|
}, [target, executeOperation]);
|
||||||
|
|
||||||
@@ -223,28 +232,42 @@ export const useLifecycleExplorer = (
|
|||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
generationRef.current++;
|
generationRef.current++;
|
||||||
const generation = generationRef.current;
|
artifactGenerationRef.current++;
|
||||||
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
deploymentGenerationRef.current++;
|
||||||
|
runGenerationRef.current++;
|
||||||
|
const artifactGeneration = artifactGenerationRef.current;
|
||||||
|
const deploymentGeneration = deploymentGenerationRef.current;
|
||||||
|
const runGeneration = runGenerationRef.current;
|
||||||
|
dispatch({ type: "setArtifactListPhase", phase: "loading" });
|
||||||
|
dispatch({ type: "setDeploymentListPhase", phase: "loading" });
|
||||||
|
dispatch({ type: "setRunListPhase", phase: "loading" });
|
||||||
|
executeOperation("workflow.artifacts.list", { limit: 50 }, artifactGeneration, artifactGenerationRef, (interpreted) => {
|
||||||
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
||||||
|
}, (message) => {
|
||||||
|
dispatch({ type: "setArtifactListPhase", phase: "error", message });
|
||||||
});
|
});
|
||||||
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
|
executeOperation("workflow.deployments.list", {}, deploymentGeneration, deploymentGenerationRef, (interpreted) => {
|
||||||
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
|
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
|
||||||
|
}, (message) => {
|
||||||
|
dispatch({ type: "setDeploymentListPhase", phase: "error", message });
|
||||||
});
|
});
|
||||||
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
executeOperation("workflow.runs.list", { limit: 50 }, runGeneration, runGenerationRef, (interpreted) => {
|
||||||
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
||||||
|
}, (message) => {
|
||||||
|
dispatch({ type: "setRunListPhase", phase: "error", message });
|
||||||
});
|
});
|
||||||
}, [target, executeOperation]);
|
}, [target, executeOperation]);
|
||||||
|
|
||||||
const loadMoreArtifacts = useCallback(() => {
|
const loadMoreArtifacts = useCallback(() => {
|
||||||
const current = state.artifactList;
|
const current = state.artifactList;
|
||||||
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
||||||
generationRef.current++;
|
artifactGenerationRef.current++;
|
||||||
const generation = generationRef.current;
|
const generation = artifactGenerationRef.current;
|
||||||
executeOperation(
|
executeOperation(
|
||||||
"workflow.artifacts.list",
|
"workflow.artifacts.list",
|
||||||
{ cursor: current.value.nextCursor, limit: 50 },
|
{ cursor: current.value.nextCursor, limit: 50 },
|
||||||
generation,
|
generation,
|
||||||
generationRef,
|
artifactGenerationRef,
|
||||||
(interpreted) => {
|
(interpreted) => {
|
||||||
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
|
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
|
||||||
},
|
},
|
||||||
@@ -254,13 +277,13 @@ export const useLifecycleExplorer = (
|
|||||||
const loadMoreRuns = useCallback(() => {
|
const loadMoreRuns = useCallback(() => {
|
||||||
const current = state.runList;
|
const current = state.runList;
|
||||||
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
||||||
generationRef.current++;
|
runGenerationRef.current++;
|
||||||
const generation = generationRef.current;
|
const generation = runGenerationRef.current;
|
||||||
executeOperation(
|
executeOperation(
|
||||||
"workflow.runs.list",
|
"workflow.runs.list",
|
||||||
{ cursor: current.value.nextCursor, limit: 50 },
|
{ cursor: current.value.nextCursor, limit: 50 },
|
||||||
generation,
|
generation,
|
||||||
generationRef,
|
runGenerationRef,
|
||||||
(interpreted) => {
|
(interpreted) => {
|
||||||
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
|
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
|
|||||||
key={beat.id}
|
key={beat.id}
|
||||||
type="button"
|
type="button"
|
||||||
data-active={beat.id === activeBeat}
|
data-active={beat.id === activeBeat}
|
||||||
|
aria-current={beat.id === activeBeat ? "step" : undefined}
|
||||||
onClick={() => jump(beat.id)}
|
onClick={() => jump(beat.id)}
|
||||||
>
|
>
|
||||||
<span>{beat.lifecycleStep}</span>
|
<span>{beat.lifecycleStep}</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||||
import { presentationNodes } from "./WorkflowGraphStage.js";
|
import { presentationNodes } from "./WorkflowGraphStage.js";
|
||||||
|
|
||||||
type NodeSpotlightProps = {
|
type NodeSpotlightProps = {
|
||||||
@@ -7,22 +8,57 @@ type NodeSpotlightProps = {
|
|||||||
|
|
||||||
const nodeDescription = (nodeId: string): string => {
|
const nodeDescription = (nodeId: string): string => {
|
||||||
if (nodeId === "review_issues") {
|
if (nodeId === "review_issues") {
|
||||||
return "NodeUse of a typed interrupt boundary. It exposes request and resume schemas and waits for a submitted or cancelled outcome.";
|
return "Typed interrupt boundary. It exposes request and resume schemas, then waits for a submitted or cancelled outcome.";
|
||||||
}
|
}
|
||||||
if (nodeId === "create_issues") {
|
if (nodeId === "create_issues") {
|
||||||
return "NodeUse that writes selected review items into the local issue-board source.";
|
return "Workflow step that writes selected review items into the local issue-board source.";
|
||||||
}
|
}
|
||||||
return "NodeUse in the prepared report workflow. The presentation graph is curated, but every node maps back to real workflow/run evidence.";
|
return "Prepared report workflow step. The presentation graph is curated, but each node maps back to workflow or run evidence.";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
|
export const NodeSpotlight = ({ nodeId, close }: NodeSpotlightProps) => {
|
||||||
|
const dialogRef = useRef<HTMLElement>(null);
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
|
const node = presentationNodes.find((candidate) => candidate.id === nodeId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyFocused = document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
closeButtonRef.current?.focus();
|
||||||
|
return () => previouslyFocused?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
const focusable = [...(dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
|
||||||
|
) ?? [])].filter((element) => !element.hasAttribute("disabled"));
|
||||||
|
if (focusable.length === 0) return;
|
||||||
|
const first = focusable[0]!;
|
||||||
|
const last = focusable[focusable.length - 1]!;
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="node-spotlight" role="dialog" aria-modal="true" aria-label={node.label}>
|
<aside
|
||||||
<button type="button" onClick={close}>Close</button>
|
ref={dialogRef}
|
||||||
<p>NodeUse</p>
|
className="node-spotlight"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={node.label}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<button type="button" ref={closeButtonRef} onClick={close}>Close</button>
|
||||||
|
<p>Workflow node</p>
|
||||||
<h2>{node.label}</h2>
|
<h2>{node.label}</h2>
|
||||||
<p>{nodeDescription(node.id)}</p>
|
<p>{nodeDescription(node.id)}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export const OperationBlock = ({ event }: OperationBlockProps) => (
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer>
|
<footer>
|
||||||
<span>{event.resultingIds.deploymentId}</span>
|
<span>Deployment: {event.resultingIds.deploymentId}</span>
|
||||||
{event.resultingIds.runId && <span>{event.resultingIds.runId}</span>}
|
{event.resultingIds.runId && <span>Run: {event.resultingIds.runId}</span>}
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export const OperatorChat = ({ state }: OperatorChatProps) => (
|
|||||||
</div>
|
</div>
|
||||||
<div className="chat-message chat-message--system">
|
<div className="chat-message chat-message--system">
|
||||||
<strong>lda.chat</strong>
|
<strong>lda.chat</strong>
|
||||||
<p>Replay mode is active. Live execution is available when connected.</p>
|
<p>
|
||||||
|
{state.playbackMode === "replay"
|
||||||
|
? "Replay mode is active. Live execution is available when connected."
|
||||||
|
: "Live execution is active. Operations are being sent to the connected workflow server."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("PresentationRoute", () => {
|
|||||||
|
|
||||||
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument();
|
expect(screen.getByText(/Human approval is a typed workflow boundary/i)).toBeInTheDocument();
|
||||||
|
|
||||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight" }));
|
await userEvent.keyboard("{ArrowRight}");
|
||||||
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
|
expect(await screen.findByText(/Resuming commits the approved branch/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ describe("PresentationRoute", () => {
|
|||||||
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
||||||
|
|
||||||
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
|
||||||
expect(screen.getByText("NodeUse")).toBeInTheDocument();
|
expect(screen.getByText("Workflow node")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can advance replay far enough to show a product operation block", async () => {
|
it("can advance replay far enough to show a product operation block", async () => {
|
||||||
|
|||||||
@@ -29,12 +29,24 @@ export const PresentationRoute = () => {
|
|||||||
}
|
}
|
||||||
}, [state.beat]);
|
}, [state.beat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => {
|
||||||
|
dispatch({ type: "jump_hash", hash: window.location.hash });
|
||||||
|
};
|
||||||
|
window.addEventListener("hashchange", onHashChange);
|
||||||
|
return () => window.removeEventListener("hashchange", onHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
const isBodyEvent = target == null || target === window || target === document.body || target === document.documentElement;
|
||||||
if (event.key === " " || event.key === "ArrowRight") {
|
if (event.key === " " || event.key === "ArrowRight") {
|
||||||
|
if (!isBodyEvent) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dispatch({ type: "next" });
|
dispatch({ type: "next" });
|
||||||
} else if (event.key === "ArrowLeft") {
|
} else if (event.key === "ArrowLeft") {
|
||||||
|
if (!isBodyEvent) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dispatch({ type: "previous" });
|
dispatch({ type: "previous" });
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
@@ -57,6 +69,10 @@ export const PresentationRoute = () => {
|
|||||||
}
|
}
|
||||||
}, [demo.state.phase, demo.state.mode, demo.start]);
|
}, [demo.state.phase, demo.state.mode, demo.start]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
|
||||||
|
}, [demo.state.mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||||
<PresentationStage
|
<PresentationStage
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraph
|
|||||||
className="workflow-graph-stage__node"
|
className="workflow-graph-stage__node"
|
||||||
data-kind={node.kind}
|
data-kind={node.kind}
|
||||||
data-selected={selectedNodeId === node.id}
|
data-selected={selectedNodeId === node.id}
|
||||||
|
aria-pressed={selectedNodeId === node.id}
|
||||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
||||||
onClick={() => selectNode(node.id)}
|
onClick={() => selectNode(node.id)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationS
|
|||||||
...state,
|
...state,
|
||||||
beat,
|
beat,
|
||||||
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
|
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
|
||||||
evidenceMode: beat === "trace-evidence" ? "peek" : state.evidenceMode,
|
evidenceMode: beat === "trace-evidence" ? "peek" : "hidden",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const presentationReducer = (
|
export const presentationReducer = (
|
||||||
|
|||||||
@@ -265,6 +265,13 @@ tbody tr:hover {
|
|||||||
height: 34rem;
|
height: 34rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-height: 760px) {
|
||||||
|
.graph-content,
|
||||||
|
.workflow-graph {
|
||||||
|
height: 24rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-graph {
|
.workflow-graph {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -273,11 +280,11 @@ tbody tr:hover {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
section[aria-label="Lifecycle Explorer"]:has(.graph-content) {
|
[data-panel="lifecycle-explorer"]:has(.graph-content) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout:has(.graph-content) > section[aria-label="Source Inventory"] {
|
.app-layout:has(.graph-content) > [data-panel="source-inventory"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ const interpretNextActions = (nextActions: {
|
|||||||
warnings: nextActions.warnings,
|
warnings: nextActions.warnings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shellArg = (value: string | number): string => {
|
||||||
|
const text = String(value);
|
||||||
|
return /^[A-Za-z0-9._/@:-]+$/.test(text) ? text : `'${text.replace(/'/g, "''")}'`;
|
||||||
|
};
|
||||||
|
|
||||||
/** Adapts a snake_case run detail from the server into camelCase for the browser. */
|
/** Adapts a snake_case run detail from the server into camelCase for the browser. */
|
||||||
const interpretRunDetail = (decoded: {
|
const interpretRunDetail = (decoded: {
|
||||||
readonly run_id: string;
|
readonly run_id: string;
|
||||||
@@ -125,7 +130,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
);
|
);
|
||||||
const parts = ["uv run wf source list"];
|
const parts = ["uv run wf source list"];
|
||||||
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
||||||
if (p.cursor != null) parts.push(`--cursor ${p.cursor}`);
|
if (p.cursor != null) parts.push(`--cursor ${shellArg(p.cursor)}`);
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
},
|
},
|
||||||
interpret: (result): WorkflowSourcesListInterpreted => {
|
interpret: (result): WorkflowSourcesListInterpreted => {
|
||||||
@@ -162,6 +167,9 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
const parts = ["uv run wf artifact list"];
|
const parts = ["uv run wf artifact list"];
|
||||||
|
if (p.query != null) parts.push(`--query ${shellArg(p.query)}`);
|
||||||
|
if (p.kind != null) parts.push(`--kind ${shellArg(p.kind)}`);
|
||||||
|
if (p.cursor != null) parts.push(`--cursor ${shellArg(p.cursor)}`);
|
||||||
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
},
|
},
|
||||||
@@ -196,7 +204,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
params,
|
params,
|
||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
return `uv run wf artifact inspect ${p.artifact_id} --version ${p.version}`;
|
return `uv run wf artifact inspect ${shellArg(p.artifact_id)} --version ${p.version}`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const decoded = Schema.decodeUnknownSync(
|
const decoded = Schema.decodeUnknownSync(
|
||||||
@@ -246,7 +254,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
const p = Schema.decodeUnknownSync(
|
const p = Schema.decodeUnknownSync(
|
||||||
WorkflowDeploymentsInspectPayloadSchema,
|
WorkflowDeploymentsInspectPayloadSchema,
|
||||||
)(params, { onExcessProperty: "error" });
|
)(params, { onExcessProperty: "error" });
|
||||||
return `uv run wf deploy inspect ${p.deployment_id}`;
|
return `uv run wf deploy inspect ${shellArg(p.deployment_id)}`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const decoded = Schema.decodeUnknownSync(
|
const decoded = Schema.decodeUnknownSync(
|
||||||
@@ -273,7 +281,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
const p = Schema.decodeUnknownSync(
|
const p = Schema.decodeUnknownSync(
|
||||||
WorkflowDeploymentsValidatePayloadSchema,
|
WorkflowDeploymentsValidatePayloadSchema,
|
||||||
)(params, { onExcessProperty: "error" });
|
)(params, { onExcessProperty: "error" });
|
||||||
return `uv run wf deploy validate ${p.deployment_id}`;
|
return `uv run wf deploy validate ${shellArg(p.deployment_id)}`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const decoded = Schema.decodeUnknownSync(
|
const decoded = Schema.decodeUnknownSync(
|
||||||
@@ -300,6 +308,8 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
const parts = ["uv run wf run list"];
|
const parts = ["uv run wf run list"];
|
||||||
|
if (p.status != null) parts.push(`--status ${shellArg(p.status)}`);
|
||||||
|
if (p.cursor != null) parts.push(`--cursor ${shellArg(p.cursor)}`);
|
||||||
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
if (p.limit != null) parts.push(`--limit ${p.limit}`);
|
||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
},
|
},
|
||||||
@@ -334,7 +344,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
params,
|
params,
|
||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
return `uv run wf run inspect ${p.run_id}`;
|
return `uv run wf run inspect ${shellArg(p.run_id)}`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
||||||
@@ -367,7 +377,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
params,
|
params,
|
||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
return `uv run wf run start ${p.deployment_id} --input '<json>'`;
|
return `uv run wf run start ${shellArg(p.deployment_id)} --input '<json>'`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
||||||
@@ -387,7 +397,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
params,
|
params,
|
||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
return `uv run wf run resume ${p.run_id} --payload '<json>'`;
|
return `uv run wf run resume ${shellArg(p.run_id)} --payload '<json>'`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
||||||
@@ -407,11 +417,11 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
params,
|
params,
|
||||||
{ onExcessProperty: "error" },
|
{ onExcessProperty: "error" },
|
||||||
);
|
);
|
||||||
return `uv run wf run trace ${p.run_id} --from ${p.trace_range.start} --limit ${p.trace_range.limit}`;
|
return `uv run wf run trace ${shellArg(p.run_id)} --from ${p.trace_range.start} --limit ${p.trace_range.limit}`;
|
||||||
},
|
},
|
||||||
interpret: (result) => {
|
interpret: (result) => {
|
||||||
const r = result as Record<string, unknown>;
|
const decoded = Schema.decodeUnknownSync(WorkflowRunsTraceResultSchema)(result);
|
||||||
const trace = (r.trace as ReadonlyArray<Record<string, unknown>> ?? []).map((entry) => ({
|
const trace = decoded.trace.map((entry) => ({
|
||||||
nodeId: entry.node_id,
|
nodeId: entry.node_id,
|
||||||
stepType: entry.step_type,
|
stepType: entry.step_type,
|
||||||
outcome: entry.outcome,
|
outcome: entry.outcome,
|
||||||
@@ -420,12 +430,12 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
|||||||
stateChanges: entry.state_changes,
|
stateChanges: entry.state_changes,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
runId: r.run_id,
|
runId: decoded.run_id,
|
||||||
status: r.status,
|
status: decoded.status,
|
||||||
frames: trace,
|
frames: trace,
|
||||||
traceStart: r.trace_start,
|
traceStart: decoded.trace_start,
|
||||||
traceLimit: r.trace_limit,
|
traceLimit: decoded.trace_limit,
|
||||||
traceTruncated: r.trace_truncated,
|
traceTruncated: decoded.trace_truncated,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user