fix: make console graph demo usable
This commit is contained in:
@@ -85,6 +85,7 @@ describe("WorkflowGraph", () => {
|
||||
expect(findNodeById(container, "start")).not.toBeNull();
|
||||
expect(findNodeById(container, "review")).not.toBeNull();
|
||||
expect(findNodeById(container, "end")).not.toBeNull();
|
||||
expect(container.querySelectorAll(".react-flow__handle")).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("calls onNodeSelect when node is clicked", () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Handle,
|
||||
Position,
|
||||
type Node,
|
||||
type Edge,
|
||||
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" : ""}`}
|
||||
style={{ borderColor: nodeColor(data) }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} />
|
||||
<div className="graph-node__label">{data.label}</div>
|
||||
{data.nodeRef && (
|
||||
<div className="graph-node__ref">{data.nodeRef}</div>
|
||||
)}
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -113,7 +116,6 @@ export const WorkflowGraph = ({ model, activeNodeId = null, onNodeSelect }: Work
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</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 () => {
|
||||
let callCount = 0;
|
||||
mockCallOperation.mockImplementation(async () => {
|
||||
|
||||
@@ -38,7 +38,9 @@ export const useLifecycleExplorer = (
|
||||
): LifecycleExplorerController => {
|
||||
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
|
||||
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 evidenceSeqRef = useRef(0);
|
||||
|
||||
@@ -81,6 +83,19 @@ export const useLifecycleExplorer = (
|
||||
});
|
||||
}
|
||||
} 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({
|
||||
type: "pushError",
|
||||
error: {
|
||||
@@ -128,14 +143,14 @@ export const useLifecycleExplorer = (
|
||||
(artifactId: string | null) => {
|
||||
dispatch({ type: "selectArtifact", artifactId });
|
||||
if (!artifactId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
artifactGenerationRef.current++;
|
||||
const generation = artifactGenerationRef.current;
|
||||
const [id, version] = artifactId.split("@");
|
||||
executeOperation(
|
||||
"workflow.artifacts.inspect",
|
||||
{ artifact_id: id, version: Number(version) },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
artifactGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setArtifactDetail", detail: decodeArtifactDetail(interpreted) });
|
||||
},
|
||||
@@ -148,15 +163,15 @@ export const useLifecycleExplorer = (
|
||||
(deploymentId: string | null) => {
|
||||
dispatch({ type: "selectDeployment", deploymentId });
|
||||
if (!deploymentId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
deploymentGenerationRef.current++;
|
||||
const generation = deploymentGenerationRef.current;
|
||||
// Deployment selection fans out to inspect + validate. Both describe the
|
||||
// same selected deployment, so they must share one generation token.
|
||||
executeOperation(
|
||||
"workflow.deployments.inspect",
|
||||
{ deployment_id: deploymentId },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
deploymentGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setDeploymentDetail", detail: decodeDeploymentDetail(interpreted) });
|
||||
},
|
||||
@@ -165,7 +180,7 @@ export const useLifecycleExplorer = (
|
||||
"workflow.deployments.validate",
|
||||
{ deployment_id: deploymentId },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
deploymentGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setDeploymentValidation", validation: decodeDeploymentValidation(interpreted) });
|
||||
},
|
||||
@@ -178,15 +193,27 @@ export const useLifecycleExplorer = (
|
||||
(runId: string | null) => {
|
||||
dispatch({ type: "selectRun", runId });
|
||||
if (!runId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
runGenerationRef.current++;
|
||||
const generation = runGenerationRef.current;
|
||||
executeOperation(
|
||||
"workflow.runs.inspect",
|
||||
{ run_id: runId },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
runGenerationRef,
|
||||
(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(
|
||||
(start: number, limit: number) => {
|
||||
if (!state.selectedRunId || !target) return;
|
||||
inspectGenerationRef.current++;
|
||||
const generation = inspectGenerationRef.current;
|
||||
runGenerationRef.current++;
|
||||
const generation = runGenerationRef.current;
|
||||
executeOperation(
|
||||
"workflow.runs.trace",
|
||||
{ run_id: state.selectedRunId, trace_range: { start, limit } },
|
||||
generation,
|
||||
inspectGenerationRef,
|
||||
runGenerationRef,
|
||||
(interpreted) => {
|
||||
dispatch({ type: "setTrace", trace: decodeTracePage(interpreted) });
|
||||
},
|
||||
|
||||
@@ -251,7 +251,7 @@ tbody tr:hover {
|
||||
/* Layout grid */
|
||||
.app-layout {
|
||||
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;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -260,6 +260,83 @@ tbody tr:hover {
|
||||
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 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
@@ -52,6 +52,22 @@ export type WorkflowSourcesListInterpreted = {
|
||||
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> = [
|
||||
{
|
||||
method: "workflow.health",
|
||||
@@ -236,7 +252,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
artifactVersion: decoded.artifact_version,
|
||||
status: decoded.status,
|
||||
diagnostics: decoded.diagnostics,
|
||||
nextActions: decoded.next_actions,
|
||||
nextActions: interpretNextActions(decoded.next_actions),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -304,7 +320,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
output: decoded.output,
|
||||
diagnostics: decoded.diagnostics,
|
||||
traceCount: decoded.trace_count,
|
||||
nextActions: decoded.next_actions,
|
||||
nextActions: interpretNextActions(decoded.next_actions),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -333,7 +349,7 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
|
||||
return {
|
||||
runId: r.run_id,
|
||||
status: r.status,
|
||||
trace,
|
||||
frames: trace,
|
||||
traceStart: r.trace_start,
|
||||
traceLimit: r.trace_limit,
|
||||
traceTruncated: r.trace_truncated,
|
||||
|
||||
@@ -93,7 +93,7 @@ const ArtifactNodeSchema = Schema.Struct({
|
||||
export const WorkflowArtifactsListResultSchema = Schema.Struct({
|
||||
nodes: Schema.Array(ArtifactNodeSchema),
|
||||
total: NonNegativeIntegerSchema,
|
||||
cursor: Schema.NullOr(Schema.String),
|
||||
cursor: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
next_cursor: Schema.NullOr(Schema.String),
|
||||
limit: Schema.optional(PositiveIntegerSchema),
|
||||
});
|
||||
|
||||
@@ -94,7 +94,6 @@ const lifecycleCases = [
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
cursor: null,
|
||||
next_cursor: null,
|
||||
limit: 50,
|
||||
},
|
||||
@@ -436,4 +435,41 @@ describe("lifecycle operations", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user