fix: address console review findings
This commit is contained in:
@@ -169,7 +169,7 @@ export const ConsoleHome = () => {
|
||||
loading={state.sourcesLoading}
|
||||
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} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ type Props = {
|
||||
|
||||
export const SourceInventory = ({ sources, loading, error }: Props) => {
|
||||
return (
|
||||
<section aria-label="Source Inventory">
|
||||
<section aria-label="Source Inventory" data-panel="source-inventory">
|
||||
<h2>Sources</h2>
|
||||
{loading && <p data-testid="sources-loading">Loading sources{"\u2026"}</p>}
|
||||
{error && (
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ldaReportSetupCommands } from "./ldaReportDemoConfig.js";
|
||||
import { DemoTimelineControls } from "./DemoTimelineControls.js";
|
||||
import { DemoTimeline } from "./DemoTimeline.js";
|
||||
import type { DemoTimelineController } from "./useDemoTimeline.js";
|
||||
|
||||
const DEFAULT_REVIEW_COMMENT = "Create selected issues before the defense.";
|
||||
|
||||
export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTimelineController }) => {
|
||||
const { state } = controller;
|
||||
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 selectedIssueIds = useMemo(() => [...selectedIds], [selectedIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIds(new Set());
|
||||
setComment(DEFAULT_REVIEW_COMMENT);
|
||||
}, [state.mode, state.phase, controller.interruptPayload]);
|
||||
|
||||
return (
|
||||
<section aria-label="lda report workflow demo" className="demo-panel">
|
||||
<div className="demo-panel__header">
|
||||
|
||||
@@ -51,6 +51,25 @@ export type LiveStepResult = {
|
||||
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 = (
|
||||
context: LiveDemoContext,
|
||||
stage: DemoEventStage,
|
||||
@@ -60,9 +79,7 @@ const eventFromResult = (
|
||||
result: RpcResponse,
|
||||
runId: string | null,
|
||||
): DemoEvent => ({
|
||||
id: `live-${context.nextSequence}-${stage}-${runId ?? "pending"}`,
|
||||
sequence: context.nextSequence,
|
||||
stage,
|
||||
...baseDemoEvent(context, stage, runId),
|
||||
operation,
|
||||
reason: result.ok ? reason : result.error.message,
|
||||
equivalentCli: result.ok ? result.equivalentCli : null,
|
||||
@@ -70,11 +87,6 @@ const eventFromResult = (
|
||||
rawResponse: result.exchange.response,
|
||||
interpreted: result.ok ? result.interpreted : null,
|
||||
durationMs: result.ok ? result.durationMs : 0,
|
||||
resultingIds: {
|
||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
||||
runId,
|
||||
},
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const syntheticEvent = (
|
||||
@@ -85,9 +97,7 @@ const syntheticEvent = (
|
||||
runId: string | null,
|
||||
sequenceOffset = 0,
|
||||
): DemoEvent => ({
|
||||
id: `live-${context.nextSequence + sequenceOffset}-${stage}-${runId ?? "pending"}`,
|
||||
sequence: context.nextSequence + sequenceOffset,
|
||||
stage,
|
||||
...baseDemoEvent(context, stage, runId, sequenceOffset),
|
||||
operation: null,
|
||||
reason,
|
||||
equivalentCli: null,
|
||||
@@ -95,8 +105,6 @@ const syntheticEvent = (
|
||||
rawResponse: null,
|
||||
interpreted,
|
||||
durationMs: 0,
|
||||
resultingIds: { deploymentId: LDA_REPORT_DEPLOYMENT_ID, runId },
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const operationForStage = (stage: LiveDemoStage): string | null => {
|
||||
@@ -118,9 +126,7 @@ export const failedLiveDemoEvent = (
|
||||
context: LiveDemoContext,
|
||||
reason: string,
|
||||
): DemoEvent => ({
|
||||
id: `live-${context.nextSequence}-failed-${context.runId ?? "pending"}`,
|
||||
sequence: context.nextSequence,
|
||||
stage: "failed",
|
||||
...baseDemoEvent(context, "failed", context.runId),
|
||||
operation: operationForStage(context.nextStage),
|
||||
reason,
|
||||
equivalentCli: null,
|
||||
@@ -128,11 +134,6 @@ export const failedLiveDemoEvent = (
|
||||
rawResponse: null,
|
||||
interpreted: null,
|
||||
durationMs: 0,
|
||||
resultingIds: {
|
||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
||||
runId: context.runId,
|
||||
},
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
export const executeLiveDemoStep = async (
|
||||
@@ -238,6 +239,19 @@ export const executeLiveDemoStep = async (
|
||||
};
|
||||
}
|
||||
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);
|
||||
return {
|
||||
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);
|
||||
recordEvidenceRef.current = recordEvidence;
|
||||
const inFlightRef = useRef(false);
|
||||
const generationRef = useRef(0);
|
||||
const [inFlight, setInFlight] = useState(false);
|
||||
const approvalRef = useRef<DemoApproval | null>(null);
|
||||
const activeRecording = useRef(loadCanonicalDemoRecording());
|
||||
@@ -71,9 +72,25 @@ export const useDemoTimeline = (
|
||||
const [output, setOutput] = useState<LdaReportOutput | 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 () => {
|
||||
if (inFlightRef.current) return;
|
||||
if (state.appliedCount >= state.events.length && state.mode === "replay") return;
|
||||
const generation = generationRef.current;
|
||||
inFlightRef.current = true;
|
||||
setInFlight(true);
|
||||
try {
|
||||
@@ -102,6 +119,7 @@ export const useDemoTimeline = (
|
||||
const approval = approvalRef.current;
|
||||
approvalRef.current = null;
|
||||
const result = await executeLiveDemoStep(target, liveContextRef.current, approval ?? undefined);
|
||||
if (generation !== generationRef.current) return;
|
||||
liveContextRef.current = result.context;
|
||||
for (const event of result.events) {
|
||||
dispatch({ type: "append_live_event", event });
|
||||
@@ -162,8 +180,10 @@ export const useDemoTimeline = (
|
||||
dispatch({ type: "fail", message });
|
||||
}
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
setInFlight(false);
|
||||
if (generation === generationRef.current) {
|
||||
inFlightRef.current = false;
|
||||
setInFlight(false);
|
||||
}
|
||||
}
|
||||
}, [state.mode, state.appliedCount, state.events, target]);
|
||||
|
||||
@@ -177,25 +197,19 @@ export const useDemoTimeline = (
|
||||
}, [state.phase, state.autoplay, state.appliedCount, step]);
|
||||
|
||||
const setMode = useCallback((mode: DemoMode) => {
|
||||
resetRuntime();
|
||||
dispatch({ type: "set_mode", mode });
|
||||
liveContextRef.current = initialLiveDemoContext;
|
||||
setInterruptPayload(null);
|
||||
setOutput(null);
|
||||
setTrace(null);
|
||||
}, []);
|
||||
}, [resetRuntime]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
resetRuntime();
|
||||
if (state.mode === "replay") {
|
||||
const recording = activeRecording.current;
|
||||
dispatch({ type: "start", mode: "replay", events: recording.events });
|
||||
} else {
|
||||
liveContextRef.current = initialLiveDemoContext;
|
||||
dispatch({ type: "start", mode: "live", events: [] });
|
||||
}
|
||||
setInterruptPayload(null);
|
||||
setOutput(null);
|
||||
setTrace(null);
|
||||
}, [state.mode]);
|
||||
}, [resetRuntime, state.mode]);
|
||||
|
||||
const pause = useCallback(() => dispatch({ type: "pause" }), []);
|
||||
const play = useCallback(() => dispatch({ type: "play" }), []);
|
||||
@@ -229,12 +243,9 @@ export const useDemoTimeline = (
|
||||
}, []);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
resetRuntime();
|
||||
dispatch({ type: "restart" });
|
||||
liveContextRef.current = initialLiveDemoContext;
|
||||
setInterruptPayload(null);
|
||||
setOutput(null);
|
||||
setTrace(null);
|
||||
}, []);
|
||||
}, [resetRuntime]);
|
||||
|
||||
return {
|
||||
state,
|
||||
|
||||
@@ -96,6 +96,18 @@ describe("WorkflowGraph", () => {
|
||||
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", () => {
|
||||
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
|
||||
const reviewNode = findNodeById(container, "review");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, type KeyboardEvent } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -39,10 +39,16 @@ const nodeColor = (data: WorkflowGraphNodeData): string => {
|
||||
|
||||
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
|
||||
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 (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-active={isActive}
|
||||
data-node-id={data.nodeId}
|
||||
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,
|
||||
type: "custom",
|
||||
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(
|
||||
|
||||
@@ -64,6 +64,24 @@ describe("buildWorkflowGraph", () => {
|
||||
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", () => {
|
||||
const model = buildWorkflowGraph(samplePlan);
|
||||
expect(model.edges.length).toBe(5);
|
||||
|
||||
@@ -15,6 +15,7 @@ export type WorkflowGraphNodeData = {
|
||||
readonly label: string;
|
||||
readonly nodeRef: string | null;
|
||||
readonly raw: Readonly<Record<string, unknown>>;
|
||||
readonly onSelect?: (nodeId: string) => void;
|
||||
};
|
||||
|
||||
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 === "foreach") return "For Each";
|
||||
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;
|
||||
if (nodeRef) {
|
||||
const parts = nodeRef.split(".");
|
||||
|
||||
@@ -150,4 +150,55 @@ describe("LifecycleExplorer", () => {
|
||||
render(<LifecycleExplorer controller={controller} />);
|
||||
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 { buildTraceFrames } from "../execution/trace-model.js";
|
||||
import { ExecutionView } from "../execution/ExecutionView.js";
|
||||
import type { LoadState } from "./state.js";
|
||||
|
||||
type LifecycleExplorerProps = {
|
||||
readonly controller: LifecycleExplorerController;
|
||||
@@ -13,18 +14,24 @@ type LifecycleExplorerProps = {
|
||||
|
||||
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) => {
|
||||
const { state } = controller;
|
||||
const [focusMode, setFocusMode] = useState<FocusMode>("lifecycle");
|
||||
|
||||
const artifacts =
|
||||
state.artifactList.phase === "loaded" ? state.artifactList.value.items : [];
|
||||
const deployments =
|
||||
state.deploymentList.phase === "loaded"
|
||||
? state.deploymentList.value.items
|
||||
: [];
|
||||
const runs =
|
||||
state.runList.phase === "loaded" ? state.runList.value.items : [];
|
||||
const artifacts = loadedItems(state.artifactList);
|
||||
const deployments = loadedItems(state.deploymentList);
|
||||
const runs = loadedItems(state.runList);
|
||||
|
||||
const graphModel = useMemo(() => {
|
||||
if (!state.artifactDetail?.plan) return null;
|
||||
@@ -74,6 +81,16 @@ export const LifecycleExplorer = ({ controller }: LifecycleExplorerProps) => {
|
||||
|
||||
{focusMode === "lifecycle" && (
|
||||
<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
|
||||
artifacts={artifacts}
|
||||
deployments={deployments}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
|
||||
|
||||
type RecordColumnsProps = {
|
||||
readonly artifacts: ArtifactSummary[];
|
||||
readonly deployments: DeploymentSummary[];
|
||||
readonly runs: RunSummary[];
|
||||
readonly artifacts: ReadonlyArray<ArtifactSummary>;
|
||||
readonly deployments: ReadonlyArray<DeploymentSummary>;
|
||||
readonly runs: ReadonlyArray<RunSummary>;
|
||||
readonly selectedArtifactId: string | null;
|
||||
readonly selectedDeploymentId: 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 type { OperationName } from "../connection/contracts.js";
|
||||
import {
|
||||
@@ -26,15 +26,7 @@ export type LifecycleExplorerController = {
|
||||
|
||||
export const useLifecycleExplorer = (
|
||||
target: string | null,
|
||||
recordEvidence: (record: {
|
||||
id: string;
|
||||
operation: string;
|
||||
label: string;
|
||||
equivalentCli: string;
|
||||
request: unknown;
|
||||
response: unknown;
|
||||
durationMs: number;
|
||||
}) => void,
|
||||
recordEvidence: (record: EvidenceRecord) => void,
|
||||
): LifecycleExplorerController => {
|
||||
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
|
||||
const generationRef = useRef(0);
|
||||
@@ -49,8 +41,9 @@ export const useLifecycleExplorer = (
|
||||
operation: OperationName,
|
||||
params: unknown,
|
||||
generation: number,
|
||||
checkGenerationRef: React.MutableRefObject<number>,
|
||||
checkGenerationRef: MutableRefObject<number>,
|
||||
onSuccess: (interpreted: unknown) => void,
|
||||
onFailure?: (message: string) => void,
|
||||
) => {
|
||||
if (!target) return;
|
||||
try {
|
||||
@@ -83,6 +76,7 @@ export const useLifecycleExplorer = (
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onFailure?.(result.error.message);
|
||||
const seq = evidenceSeqRef.current++;
|
||||
const record: EvidenceRecord = {
|
||||
id: `${operation}-${seq}`,
|
||||
@@ -106,11 +100,14 @@ export const useLifecycleExplorer = (
|
||||
});
|
||||
}
|
||||
} catch (rpcError) {
|
||||
if (generation !== checkGenerationRef.current) return;
|
||||
const message = rpcError instanceof Error ? rpcError.message : String(rpcError);
|
||||
onFailure?.(message);
|
||||
dispatch({
|
||||
type: "pushError",
|
||||
error: {
|
||||
operation,
|
||||
message: rpcError instanceof Error ? rpcError.message : String(rpcError),
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
@@ -122,20 +119,32 @@ export const useLifecycleExplorer = (
|
||||
useEffect(() => {
|
||||
if (!target) return;
|
||||
generationRef.current++;
|
||||
artifactGenerationRef.current++;
|
||||
deploymentGenerationRef.current++;
|
||||
runGenerationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
rawEvidenceRef.current = [];
|
||||
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) => {
|
||||
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
|
||||
}, (message) => {
|
||||
dispatch({ type: "setArtifactListPhase", phase: "error", message });
|
||||
});
|
||||
|
||||
executeOperation("workflow.deployments.list", {}, generation, generationRef, (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) => {
|
||||
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
|
||||
}, (message) => {
|
||||
dispatch({ type: "setRunListPhase", phase: "error", message });
|
||||
});
|
||||
}, [target, executeOperation]);
|
||||
|
||||
@@ -223,28 +232,42 @@ export const useLifecycleExplorer = (
|
||||
const refresh = useCallback(() => {
|
||||
if (!target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
|
||||
artifactGenerationRef.current++;
|
||||
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) });
|
||||
}, (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) });
|
||||
}, (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) });
|
||||
}, (message) => {
|
||||
dispatch({ type: "setRunListPhase", phase: "error", message });
|
||||
});
|
||||
}, [target, executeOperation]);
|
||||
|
||||
const loadMoreArtifacts = useCallback(() => {
|
||||
const current = state.artifactList;
|
||||
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
artifactGenerationRef.current++;
|
||||
const generation = artifactGenerationRef.current;
|
||||
executeOperation(
|
||||
"workflow.artifacts.list",
|
||||
{ cursor: current.value.nextCursor, limit: 50 },
|
||||
generation,
|
||||
generationRef,
|
||||
artifactGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
|
||||
},
|
||||
@@ -254,13 +277,13 @@ export const useLifecycleExplorer = (
|
||||
const loadMoreRuns = useCallback(() => {
|
||||
const current = state.runList;
|
||||
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
|
||||
generationRef.current++;
|
||||
const generation = generationRef.current;
|
||||
runGenerationRef.current++;
|
||||
const generation = runGenerationRef.current;
|
||||
executeOperation(
|
||||
"workflow.runs.list",
|
||||
{ cursor: current.value.nextCursor, limit: 50 },
|
||||
generation,
|
||||
generationRef,
|
||||
runGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ export const BeatRail = ({ activeBeat, jump }: BeatRailProps) => (
|
||||
key={beat.id}
|
||||
type="button"
|
||||
data-active={beat.id === activeBeat}
|
||||
aria-current={beat.id === activeBeat ? "step" : undefined}
|
||||
onClick={() => jump(beat.id)}
|
||||
>
|
||||
<span>{beat.lifecycleStep}</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import { presentationNodes } from "./WorkflowGraphStage.js";
|
||||
|
||||
type NodeSpotlightProps = {
|
||||
@@ -7,22 +8,57 @@ type NodeSpotlightProps = {
|
||||
|
||||
const nodeDescription = (nodeId: string): string => {
|
||||
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") {
|
||||
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) => {
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
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;
|
||||
|
||||
return (
|
||||
<aside className="node-spotlight" role="dialog" aria-modal="true" aria-label={node.label}>
|
||||
<button type="button" onClick={close}>Close</button>
|
||||
<p>NodeUse</p>
|
||||
<aside
|
||||
ref={dialogRef}
|
||||
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>
|
||||
<p>{nodeDescription(node.id)}</p>
|
||||
</aside>
|
||||
|
||||
@@ -25,8 +25,8 @@ export const OperationBlock = ({ event }: OperationBlockProps) => (
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{event.resultingIds.deploymentId}</span>
|
||||
{event.resultingIds.runId && <span>{event.resultingIds.runId}</span>}
|
||||
<span>Deployment: {event.resultingIds.deploymentId}</span>
|
||||
{event.resultingIds.runId && <span>Run: {event.resultingIds.runId}</span>}
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,11 @@ export const OperatorChat = ({ state }: OperatorChatProps) => (
|
||||
</div>
|
||||
<div className="chat-message chat-message--system">
|
||||
<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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ describe("PresentationRoute", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("PresentationRoute", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: /issue review/i }));
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -29,12 +29,24 @@ export const PresentationRoute = () => {
|
||||
}
|
||||
}, [state.beat]);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
dispatch({ type: "jump_hash", hash: window.location.hash });
|
||||
};
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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 (!isBodyEvent) return;
|
||||
event.preventDefault();
|
||||
dispatch({ type: "next" });
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
if (!isBodyEvent) return;
|
||||
event.preventDefault();
|
||||
dispatch({ type: "previous" });
|
||||
} else if (event.key === "Escape") {
|
||||
@@ -57,6 +69,10 @@ export const PresentationRoute = () => {
|
||||
}
|
||||
}, [demo.state.phase, demo.state.mode, demo.start]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
|
||||
}, [demo.state.mode]);
|
||||
|
||||
return (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||
<PresentationStage
|
||||
|
||||
@@ -28,6 +28,7 @@ export const WorkflowGraphStage = ({ selectedNodeId, selectNode }: WorkflowGraph
|
||||
className="workflow-graph-stage__node"
|
||||
data-kind={node.kind}
|
||||
data-selected={selectedNodeId === node.id}
|
||||
aria-pressed={selectedNodeId === node.id}
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%` }}
|
||||
onClick={() => selectNode(node.id)}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,7 @@ const withDerivedModes = (state: PresentationState, beat: BeatId): PresentationS
|
||||
...state,
|
||||
beat,
|
||||
chatMode: beat === "intro" || beat === "chat-request" ? "full" : "rail",
|
||||
evidenceMode: beat === "trace-evidence" ? "peek" : state.evidenceMode,
|
||||
evidenceMode: beat === "trace-evidence" ? "peek" : "hidden",
|
||||
});
|
||||
|
||||
export const presentationReducer = (
|
||||
|
||||
@@ -265,6 +265,13 @@ tbody tr:hover {
|
||||
height: 34rem;
|
||||
}
|
||||
|
||||
@media (max-height: 760px) {
|
||||
.graph-content,
|
||||
.workflow-graph {
|
||||
height: 24rem;
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-graph {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -273,11 +280,11 @@ tbody tr:hover {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
section[aria-label="Lifecycle Explorer"]:has(.graph-content) {
|
||||
[data-panel="lifecycle-explorer"]:has(.graph-content) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@ const interpretNextActions = (nextActions: {
|
||||
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. */
|
||||
const interpretRunDetail = (decoded: {
|
||||
readonly run_id: string;
|
||||
@@ -125,7 +130,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
);
|
||||
const parts = ["uv run wf source list"];
|
||||
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(" ");
|
||||
},
|
||||
interpret: (result): WorkflowSourcesListInterpreted => {
|
||||
@@ -162,6 +167,9 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
{ onExcessProperty: "error" },
|
||||
);
|
||||
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}`);
|
||||
return parts.join(" ");
|
||||
},
|
||||
@@ -196,7 +204,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
params,
|
||||
{ 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) => {
|
||||
const decoded = Schema.decodeUnknownSync(
|
||||
@@ -246,7 +254,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
const p = Schema.decodeUnknownSync(
|
||||
WorkflowDeploymentsInspectPayloadSchema,
|
||||
)(params, { onExcessProperty: "error" });
|
||||
return `uv run wf deploy inspect ${p.deployment_id}`;
|
||||
return `uv run wf deploy inspect ${shellArg(p.deployment_id)}`;
|
||||
},
|
||||
interpret: (result) => {
|
||||
const decoded = Schema.decodeUnknownSync(
|
||||
@@ -273,7 +281,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
const p = Schema.decodeUnknownSync(
|
||||
WorkflowDeploymentsValidatePayloadSchema,
|
||||
)(params, { onExcessProperty: "error" });
|
||||
return `uv run wf deploy validate ${p.deployment_id}`;
|
||||
return `uv run wf deploy validate ${shellArg(p.deployment_id)}`;
|
||||
},
|
||||
interpret: (result) => {
|
||||
const decoded = Schema.decodeUnknownSync(
|
||||
@@ -300,6 +308,8 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
{ onExcessProperty: "error" },
|
||||
);
|
||||
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}`);
|
||||
return parts.join(" ");
|
||||
},
|
||||
@@ -334,7 +344,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
params,
|
||||
{ onExcessProperty: "error" },
|
||||
);
|
||||
return `uv run wf run inspect ${p.run_id}`;
|
||||
return `uv run wf run inspect ${shellArg(p.run_id)}`;
|
||||
},
|
||||
interpret: (result) => {
|
||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
||||
@@ -367,7 +377,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
params,
|
||||
{ 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) => {
|
||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
||||
@@ -387,7 +397,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
params,
|
||||
{ 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) => {
|
||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
|
||||
@@ -407,11 +417,11 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
params,
|
||||
{ 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) => {
|
||||
const r = result as Record<string, unknown>;
|
||||
const trace = (r.trace as ReadonlyArray<Record<string, unknown>> ?? []).map((entry) => ({
|
||||
const decoded = Schema.decodeUnknownSync(WorkflowRunsTraceResultSchema)(result);
|
||||
const trace = decoded.trace.map((entry) => ({
|
||||
nodeId: entry.node_id,
|
||||
stepType: entry.step_type,
|
||||
outcome: entry.outcome,
|
||||
@@ -420,12 +430,12 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
stateChanges: entry.state_changes,
|
||||
}));
|
||||
return {
|
||||
runId: r.run_id,
|
||||
status: r.status,
|
||||
runId: decoded.run_id,
|
||||
status: decoded.status,
|
||||
frames: trace,
|
||||
traceStart: r.trace_start,
|
||||
traceLimit: r.trace_limit,
|
||||
traceTruncated: r.trace_truncated,
|
||||
traceStart: decoded.trace_start,
|
||||
traceLimit: decoded.trace_limit,
|
||||
traceTruncated: decoded.trace_truncated,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user