fix: make console graph demo usable

This commit is contained in:
lda
2026-07-02 23:14:06 +07:00 Verified
parent 3cdcf6ff3b
commit 86ccbe35cf
9 changed files with 1560 additions and 23 deletions
File diff suppressed because it is too large Load Diff
@@ -85,6 +85,7 @@ describe("WorkflowGraph", () => {
expect(findNodeById(container, "start")).not.toBeNull(); expect(findNodeById(container, "start")).not.toBeNull();
expect(findNodeById(container, "review")).not.toBeNull(); expect(findNodeById(container, "review")).not.toBeNull();
expect(findNodeById(container, "end")).not.toBeNull(); expect(findNodeById(container, "end")).not.toBeNull();
expect(container.querySelectorAll(".react-flow__handle")).toHaveLength(6);
}); });
it("calls onNodeSelect when node is clicked", () => { it("calls onNodeSelect when node is clicked", () => {
+4 -2
View File
@@ -3,7 +3,8 @@ import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
MiniMap, Handle,
Position,
type Node, type Node,
type Edge, type Edge,
type NodeTypes, type NodeTypes,
@@ -47,10 +48,12 @@ const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected:
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" : ""}`}
style={{ borderColor: nodeColor(data) }} style={{ borderColor: nodeColor(data) }}
> >
<Handle type="target" position={Position.Top} />
<div className="graph-node__label">{data.label}</div> <div className="graph-node__label">{data.label}</div>
{data.nodeRef && ( {data.nodeRef && (
<div className="graph-node__ref">{data.nodeRef}</div> <div className="graph-node__ref">{data.nodeRef}</div>
)} )}
<Handle type="source" position={Position.Bottom} />
</div> </div>
); );
}; };
@@ -113,7 +116,6 @@ export const WorkflowGraph = ({ model, activeNodeId = null, onNodeSelect }: Work
> >
<Background /> <Background />
<Controls /> <Controls />
<MiniMap />
</ReactFlow> </ReactFlow>
</div> </div>
); );
@@ -161,6 +161,119 @@ describe("useLifecycleExplorer", () => {
}); });
}); });
it("keeps deployment validation when a run selection happens before validation returns", async () => {
let releaseValidation: (() => void) | null = null;
const validationGate = new Promise<void>((resolve) => {
releaseValidation = resolve;
});
mockCallOperation.mockImplementation(async (operation: string) => {
if (operation === "workflow.deployments.validate") {
await validationGate;
return {
ok: true,
operation,
interpreted: {
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "runnable",
diagnostics: [],
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: null,
reason: "deployment is runnable",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy validate report.default",
durationMs: 5,
};
}
if (operation === "workflow.deployments.inspect") {
return {
ok: true,
operation,
interpreted: {
id: "report.default",
artifactId: "report",
artifactVersion: 1,
bindings: [],
driftPolicy: "block",
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf deploy inspect report.default",
durationMs: 5,
};
}
if (operation === "workflow.runs.inspect") {
return {
ok: true,
operation,
interpreted: {
runId: "run_1",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "ok",
error: null,
output: {},
diagnostics: [],
traceCount: 0,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "done",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run inspect run_1",
durationMs: 5,
};
}
return {
ok: true,
operation,
interpreted:
operation === "workflow.deployments.list"
? { items: [] }
: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "",
durationMs: 5,
};
});
const recordEvidence = vi.fn();
const { result } = renderHook(() =>
useLifecycleExplorer("http://127.0.0.1:8000/rpc", recordEvidence),
);
await act(async () => {
result.current.selectDeployment("report.default");
result.current.selectRun("run_1");
});
await act(async () => {
releaseValidation?.();
await validationGate;
});
await waitFor(() => {
expect(result.current.state.runDetail?.runId).toBe("run_1");
expect(result.current.state.deploymentValidation?.status).toBe("runnable");
});
});
it("ignores stale responses after target change", async () => { it("ignores stale responses after target change", async () => {
let callCount = 0; let callCount = 0;
mockCallOperation.mockImplementation(async () => { mockCallOperation.mockImplementation(async () => {
@@ -38,7 +38,9 @@ export const useLifecycleExplorer = (
): LifecycleExplorerController => { ): LifecycleExplorerController => {
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState); const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
const generationRef = useRef(0); const generationRef = useRef(0);
const inspectGenerationRef = useRef(0); const artifactGenerationRef = useRef(0);
const deploymentGenerationRef = useRef(0);
const runGenerationRef = useRef(0);
const rawEvidenceRef = useRef<ReadonlyArray<EvidenceRecord>>([]); const rawEvidenceRef = useRef<ReadonlyArray<EvidenceRecord>>([]);
const evidenceSeqRef = useRef(0); const evidenceSeqRef = useRef(0);
@@ -81,6 +83,19 @@ export const useLifecycleExplorer = (
}); });
} }
} else { } else {
const seq = evidenceSeqRef.current++;
const record: EvidenceRecord = {
id: `${operation}-${seq}`,
operation,
label: `${operation} failed`,
equivalentCli: "unavailable: operation failed before CLI metadata",
request: result.exchange.request,
response: result.exchange.response,
durationMs: 0,
};
recordEvidence(record);
rawEvidenceRef.current = [...rawEvidenceRef.current, record];
dispatch({ type: "setRawEvidence", evidence: rawEvidenceRef.current });
dispatch({ dispatch({
type: "pushError", type: "pushError",
error: { error: {
@@ -128,14 +143,14 @@ export const useLifecycleExplorer = (
(artifactId: string | null) => { (artifactId: string | null) => {
dispatch({ type: "selectArtifact", artifactId }); dispatch({ type: "selectArtifact", artifactId });
if (!artifactId || !target) return; if (!artifactId || !target) return;
inspectGenerationRef.current++; artifactGenerationRef.current++;
const generation = inspectGenerationRef.current; const generation = artifactGenerationRef.current;
const [id, version] = artifactId.split("@"); const [id, version] = artifactId.split("@");
executeOperation( executeOperation(
"workflow.artifacts.inspect", "workflow.artifacts.inspect",
{ artifact_id: id, version: Number(version) }, { artifact_id: id, version: Number(version) },
generation, generation,
inspectGenerationRef, artifactGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "setArtifactDetail", detail: decodeArtifactDetail(interpreted) }); dispatch({ type: "setArtifactDetail", detail: decodeArtifactDetail(interpreted) });
}, },
@@ -148,15 +163,15 @@ export const useLifecycleExplorer = (
(deploymentId: string | null) => { (deploymentId: string | null) => {
dispatch({ type: "selectDeployment", deploymentId }); dispatch({ type: "selectDeployment", deploymentId });
if (!deploymentId || !target) return; if (!deploymentId || !target) return;
inspectGenerationRef.current++; deploymentGenerationRef.current++;
const generation = inspectGenerationRef.current; const generation = deploymentGenerationRef.current;
// Deployment selection fans out to inspect + validate. Both describe the // Deployment selection fans out to inspect + validate. Both describe the
// same selected deployment, so they must share one generation token. // same selected deployment, so they must share one generation token.
executeOperation( executeOperation(
"workflow.deployments.inspect", "workflow.deployments.inspect",
{ deployment_id: deploymentId }, { deployment_id: deploymentId },
generation, generation,
inspectGenerationRef, deploymentGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "setDeploymentDetail", detail: decodeDeploymentDetail(interpreted) }); dispatch({ type: "setDeploymentDetail", detail: decodeDeploymentDetail(interpreted) });
}, },
@@ -165,7 +180,7 @@ export const useLifecycleExplorer = (
"workflow.deployments.validate", "workflow.deployments.validate",
{ deployment_id: deploymentId }, { deployment_id: deploymentId },
generation, generation,
inspectGenerationRef, deploymentGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "setDeploymentValidation", validation: decodeDeploymentValidation(interpreted) }); dispatch({ type: "setDeploymentValidation", validation: decodeDeploymentValidation(interpreted) });
}, },
@@ -178,15 +193,27 @@ export const useLifecycleExplorer = (
(runId: string | null) => { (runId: string | null) => {
dispatch({ type: "selectRun", runId }); dispatch({ type: "selectRun", runId });
if (!runId || !target) return; if (!runId || !target) return;
inspectGenerationRef.current++; runGenerationRef.current++;
const generation = inspectGenerationRef.current; const generation = runGenerationRef.current;
executeOperation( executeOperation(
"workflow.runs.inspect", "workflow.runs.inspect",
{ run_id: runId }, { run_id: runId },
generation, generation,
inspectGenerationRef, runGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "setRunDetail", detail: decodeRunDetail(interpreted) }); const detail = decodeRunDetail(interpreted);
dispatch({ type: "setRunDetail", detail });
if (detail.traceCount > 0) {
executeOperation(
"workflow.runs.trace",
{ run_id: runId, trace_range: { start: 0, limit: 50 } },
generation,
runGenerationRef,
(traceInterpreted) => {
dispatch({ type: "setTrace", trace: decodeTracePage(traceInterpreted) });
},
);
}
}, },
); );
}, },
@@ -243,13 +270,13 @@ export const useLifecycleExplorer = (
const loadTrace = useCallback( const loadTrace = useCallback(
(start: number, limit: number) => { (start: number, limit: number) => {
if (!state.selectedRunId || !target) return; if (!state.selectedRunId || !target) return;
inspectGenerationRef.current++; runGenerationRef.current++;
const generation = inspectGenerationRef.current; const generation = runGenerationRef.current;
executeOperation( executeOperation(
"workflow.runs.trace", "workflow.runs.trace",
{ run_id: state.selectedRunId, trace_range: { start, limit } }, { run_id: state.selectedRunId, trace_range: { start, limit } },
generation, generation,
inspectGenerationRef, runGenerationRef,
(interpreted) => { (interpreted) => {
dispatch({ type: "setTrace", trace: decodeTracePage(interpreted) }); dispatch({ type: "setTrace", trace: decodeTracePage(interpreted) });
}, },
+78 -1
View File
@@ -251,7 +251,7 @@ tbody tr:hover {
/* Layout grid */ /* Layout grid */
.app-layout { .app-layout {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(22rem, 0.7fr); grid-template-columns: minmax(24rem, 0.9fr) minmax(30rem, 1.1fr);
gap: 1rem; gap: 1rem;
align-items: start; align-items: start;
} }
@@ -260,6 +260,83 @@ tbody tr:hover {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.graph-content,
.workflow-graph {
height: 34rem;
}
.workflow-graph {
width: 100%;
border: 1px solid var(--color-border);
border-radius: 4px;
background: #fff;
overflow: hidden;
}
section[aria-label="Lifecycle Explorer"]:has(.graph-content) {
grid-column: 1 / -1;
}
.app-layout:has(.graph-content) > section[aria-label="Source Inventory"] {
display: none;
}
.workflow-graph .react-flow {
height: 100%;
}
.graph-node {
min-width: 13rem;
max-width: 16rem;
padding: 0.7rem 0.85rem;
border: 2px solid var(--color-border);
border-radius: 6px;
background: #fff;
color: var(--color-ink);
box-shadow: 0 3px 0 rgba(26, 26, 26, 0.12);
}
.graph-node__label {
font-family: var(--font-heading);
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1;
text-transform: uppercase;
}
.graph-node__ref {
margin-top: 0.35rem;
font-family: var(--font-mono);
font-size: 0.74rem;
line-height: 1.25;
color: var(--color-slate);
overflow-wrap: anywhere;
}
.workflow-graph .react-flow__handle {
width: 0.55rem;
height: 0.55rem;
border: 2px solid #fff;
background: var(--color-ink);
}
.workflow-graph .react-flow__edge-path {
stroke: var(--color-slate);
stroke-width: 1.75;
}
.workflow-graph .react-flow__edge-text {
font-family: var(--font-mono);
font-size: 0.7rem;
fill: var(--color-ink);
}
.workflow-graph .react-flow__controls {
border: 1px solid var(--color-border);
box-shadow: none;
}
/* Motion */ /* Motion */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
+19 -3
View File
@@ -52,6 +52,22 @@ export type WorkflowSourcesListInterpreted = {
readonly total: number; readonly total: number;
}; };
const interpretNextActions = (nextActions: {
readonly can_continue: boolean;
readonly can_save_now: boolean | null;
readonly recommended_next_tool: string | null;
readonly reason: string;
readonly patch_examples: ReadonlyArray<unknown>;
readonly warnings: ReadonlyArray<string>;
}) => ({
canContinue: nextActions.can_continue,
canSaveNow: nextActions.can_save_now,
recommendedNextTool: nextActions.recommended_next_tool,
reason: nextActions.reason,
patchExamples: nextActions.patch_examples,
warnings: nextActions.warnings,
});
const operationEntries: ReadonlyArray<OperationMeta> = [ const operationEntries: ReadonlyArray<OperationMeta> = [
{ {
method: "workflow.health", method: "workflow.health",
@@ -236,7 +252,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
artifactVersion: decoded.artifact_version, artifactVersion: decoded.artifact_version,
status: decoded.status, status: decoded.status,
diagnostics: decoded.diagnostics, diagnostics: decoded.diagnostics,
nextActions: decoded.next_actions, nextActions: interpretNextActions(decoded.next_actions),
}; };
}, },
}, },
@@ -304,7 +320,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
output: decoded.output, output: decoded.output,
diagnostics: decoded.diagnostics, diagnostics: decoded.diagnostics,
traceCount: decoded.trace_count, traceCount: decoded.trace_count,
nextActions: decoded.next_actions, nextActions: interpretNextActions(decoded.next_actions),
}; };
}, },
}, },
@@ -333,7 +349,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
return { return {
runId: r.run_id, runId: r.run_id,
status: r.status, status: r.status,
trace, frames: trace,
traceStart: r.trace_start, traceStart: r.trace_start,
traceLimit: r.trace_limit, traceLimit: r.trace_limit,
traceTruncated: r.trace_truncated, traceTruncated: r.trace_truncated,
+1 -1
View File
@@ -93,7 +93,7 @@ const ArtifactNodeSchema = Schema.Struct({
export const WorkflowArtifactsListResultSchema = Schema.Struct({ export const WorkflowArtifactsListResultSchema = Schema.Struct({
nodes: Schema.Array(ArtifactNodeSchema), nodes: Schema.Array(ArtifactNodeSchema),
total: NonNegativeIntegerSchema, total: NonNegativeIntegerSchema,
cursor: Schema.NullOr(Schema.String), cursor: Schema.optional(Schema.NullOr(Schema.String)),
next_cursor: Schema.NullOr(Schema.String), next_cursor: Schema.NullOr(Schema.String),
limit: Schema.optional(PositiveIntegerSchema), limit: Schema.optional(PositiveIntegerSchema),
}); });
+37 -1
View File
@@ -94,7 +94,6 @@ const lifecycleCases = [
}, },
], ],
total: 1, total: 1,
cursor: null,
next_cursor: null, next_cursor: null,
limit: 50, limit: 50,
}, },
@@ -436,4 +435,41 @@ describe("lifecycle operations", () => {
expect(exchange.interpreted).toBeDefined(); expect(exchange.interpreted).toBeDefined();
}); });
} }
it("interprets run trace frames with the console lifecycle shape", async () => {
const traceCase = lifecycleCases.find(
(testCase) => testCase.operation === "workflow.runs.trace",
);
expect(traceCase).toBeDefined();
if (!traceCase) return;
const fetch: typeof globalThis.fetch = async (input, init) => {
const request = await requestBody(input, init);
return jsonResponse({
jsonrpc: "2.0",
id: request.id,
result: traceCase.result,
});
};
const exchange = await runOperation(
{ fetch },
traceCase.operation as "workflow.health" | "workflow.sources.list",
traceCase.params,
);
expect(exchange.interpreted).toMatchObject({
frames: [
{
nodeId: "review",
stepType: "interrupt",
outcome: "submitted",
},
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
});
expect(exchange.interpreted).not.toHaveProperty("trace");
});
}); });