feat: add workflow console lifecycle explorer

This commit is contained in:
lda
2026-07-02 21:43:04 +07:00 Verified
parent 2cae5b59eb
commit 3cdcf6ff3b
31 changed files with 4457 additions and 12 deletions
+2
View File
@@ -10,9 +10,11 @@
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@dagrejs/dagre": "3.0.0",
"@fontsource-variable/source-sans-3": "5.2.9",
"@fontsource/barlow-condensed": "5.2.8",
"@fontsource/ibm-plex-mono": "5.2.7",
"@xyflow/react": "12.11.1",
"react": "19.2.7",
"react-dom": "19.2.7",
"valibot": "1.4.2"
+37 -4
View File
@@ -92,17 +92,28 @@ describe("App", () => {
it("ignores stale source inventory responses after reconnect", async () => {
const firstSources = deferred<RpcResponse>();
const secondSources = deferred<RpcResponse>();
const lifecycleOk: RpcResponse = {
ok: true,
operation: "workflow.artifacts.list",
label: "List artifacts",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
};
let latestSourcesDeferred = firstSources;
mockedConnectToServer
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
.mockResolvedValueOnce(successfulConnection("http://second.example/rpc"));
mockedCallOperation
.mockReturnValueOnce(firstSources.promise)
.mockReturnValueOnce(secondSources.promise);
mockedCallOperation.mockImplementation((op: string) => {
if (op === "workflow.sources.list") return latestSourcesDeferred.promise;
return Promise.resolve(lifecycleOk);
});
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await screen.findByTestId("sources-loading");
latestSourcesDeferred = secondSources;
await userEvent.click(screen.getByRole("button", { name: "Reconnect" }));
secondSources.resolve(successfulSources("local.second"));
firstSources.resolve(successfulSources("local.first"));
@@ -114,4 +125,26 @@ describe("App", () => {
expect(screen.queryByTestId("source-id-local.first")).toBeNull();
});
});
it("mounts lifecycle explorer after connect", async () => {
mockedConnectToServer.mockResolvedValue(
successfulConnection("http://127.0.0.1:8765/rpc"),
);
mockedCallOperation.mockResolvedValue({
ok: true,
operation: "workflow.sources.list",
label: "List sources",
interpreted: { sources: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf source list",
durationMs: 5,
});
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
await waitFor(() => {
expect(screen.getByTestId("lifecycle-explorer")).toBeInTheDocument();
});
});
});
+15 -2
View File
@@ -2,12 +2,14 @@ import { useReducer, useEffect, useCallback, useRef } from "react";
import {
connectionReducer,
initialState,
type EvidenceRecord,
type SourceRecord,
} from "./state.js";
import { connectToServer, callOperation } from "../connection/api.js";
import { ConnectionHeader } from "../components/ConnectionHeader.js";
import { SourceInventory } from "../components/SourceInventory.js";
import { ProtocolEvidence } from "../components/ProtocolEvidence.js";
import { LifecycleExplorer } from "../lifecycle/LifecycleExplorer.js";
import { useLifecycleExplorer } from "../lifecycle/useLifecycleExplorer.js";
const parseSources = (
data: unknown,
@@ -44,6 +46,15 @@ export const App = () => {
const connectGeneration = useRef(0);
const sourcesGeneration = useRef(0);
const connectedTarget = state.phase === "connected" ? state.connectedTarget : null;
const recordEvidence = useCallback(
(record: EvidenceRecord) => dispatch({ type: "evidence_recorded", record }),
[],
);
const lifecycleController = useLifecycleExplorer(connectedTarget, recordEvidence);
const loadSources = useCallback(
async (target: string) => {
const generation = ++sourcesGeneration.current;
@@ -154,7 +165,9 @@ export const App = () => {
loading={state.sourcesLoading}
error={state.sourceError}
/>
<ProtocolEvidence evidence={state.evidence} />
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer">
<LifecycleExplorer controller={lifecycleController} />
</section>
</div>
);
};
@@ -42,6 +42,14 @@ const ConnectionSuccessSchema = v.object({
const OperationNameSchema = v.union([
v.literal("workflow.health"),
v.literal("workflow.sources.list"),
v.literal("workflow.artifacts.list"),
v.literal("workflow.artifacts.inspect"),
v.literal("workflow.deployments.list"),
v.literal("workflow.deployments.inspect"),
v.literal("workflow.deployments.validate"),
v.literal("workflow.runs.list"),
v.literal("workflow.runs.inspect"),
v.literal("workflow.runs.trace"),
]);
const OperationSuccessSchema = v.object({
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ExecutionView } from "./ExecutionView.js";
import type { TraceFrameView } from "./trace-model.js";
afterEach(() => {
cleanup();
});
const mockFrames: TraceFrameView[] = [
{
nodeId: "start",
stepType: "use",
outcome: "ok",
inputSummary: "{}",
outputSummary: "{ report_id: string }",
stateChangeCount: 0,
raw: {},
},
{
nodeId: "review",
stepType: "interrupt",
outcome: "submitted",
inputSummary: "{ report: string }",
outputSummary: "{}",
stateChangeCount: 1,
raw: {},
},
];
const mockInterrupt = {
kind: "human",
payload: { report: "Please review" },
outcomes: ["submitted", "rejected"],
requestSchema: { type: "object", properties: { decision: { type: "string" } } },
resumeSchema: { type: "object", properties: { decision: { type: "string" } } },
typed: true,
};
describe("ExecutionView", () => {
it("renders trace frames", () => {
render(<ExecutionView frames={mockFrames} />);
expect(screen.getByText("start")).toBeInTheDocument();
expect(screen.getByText("review")).toBeInTheDocument();
});
it("shows step types", () => {
render(<ExecutionView frames={mockFrames} />);
expect(screen.getAllByText("use").length).toBeGreaterThan(0);
expect(screen.getByText("interrupt")).toBeInTheDocument();
});
it("shows outcomes", () => {
render(<ExecutionView frames={mockFrames} />);
expect(screen.getAllByText("ok").length).toBeGreaterThan(0);
expect(screen.getByText("submitted")).toBeInTheDocument();
});
it("calls onSelectNode when frame is clicked", () => {
const onSelect = vi.fn();
render(<ExecutionView frames={mockFrames} onSelectNode={onSelect} />);
const reviewNodes = screen.getAllByText("review");
fireEvent.click(reviewNodes[0]!);
expect(onSelect).toHaveBeenCalledWith("review");
});
it("renders interrupt details", () => {
render(<ExecutionView frames={mockFrames} interrupt={mockInterrupt} />);
expect(screen.getByText(/human/i)).toBeInTheDocument();
expect(screen.getByText(/submitted, rejected/i)).toBeInTheDocument();
});
it("shows empty state when no frames", () => {
render(<ExecutionView frames={[]} />);
expect(screen.getByText(/no frames/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
import type { TraceFrameView } from "./trace-model.js";
type InterruptInfo = {
readonly kind: string;
readonly payload: Record<string, unknown>;
readonly outcomes: ReadonlyArray<string>;
readonly requestSchema: Record<string, unknown>;
readonly resumeSchema: Record<string, unknown>;
readonly typed: boolean;
};
type ExecutionViewProps = {
readonly frames: ReadonlyArray<TraceFrameView>;
readonly interrupt?: InterruptInfo | null;
readonly onSelectNode?: (nodeId: string) => void;
};
export const ExecutionView = ({ frames, interrupt = null, onSelectNode }: ExecutionViewProps) => {
if (frames.length === 0) {
return (
<div className="execution-view execution-view--empty">
No frames in this trace
</div>
);
}
return (
<div className="execution-view">
<div className="execution-view__frames">
<h3>Trace Frames</h3>
<ul className="frame-list">
{frames.map((frame, index) => (
<li
key={`${frame.nodeId}-${index}`}
className="frame-item"
role="button"
tabIndex={0}
onClick={() => onSelectNode?.(frame.nodeId)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onSelectNode?.(frame.nodeId);
}
}}
>
<span className="frame-item__node">{frame.nodeId}</span>
<span className="frame-item__type">{frame.stepType}</span>
<span className="frame-item__outcome">{frame.outcome}</span>
</li>
))}
</ul>
</div>
{interrupt && (
<div className="execution-view__interrupt">
<h3>Interrupt Block</h3>
<dl>
<dt>Kind</dt>
<dd>{interrupt.kind}</dd>
<dt>Outcomes</dt>
<dd>{interrupt.outcomes.join(", ")}</dd>
<dt>Typed</dt>
<dd>{interrupt.typed ? "Yes" : "No"}</dd>
</dl>
</div>
)}
</div>
);
};
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { buildTraceFrames, type TraceFrameView } from "./trace-model.js";
const sampleTracePage = {
frames: [
{
nodeId: "start",
stepType: "use",
resolvedInput: {},
outcome: "ok",
output: { report_id: "rpt_1" },
stateChanges: {},
},
{
nodeId: "review",
stepType: "interrupt",
resolvedInput: { report: "..." },
outcome: "submitted",
output: {},
stateChanges: { status: "reviewed" },
},
{
nodeId: "create_issues",
stepType: "use",
resolvedInput: { report_id: "rpt_1" },
outcome: "ok",
output: { issues_created: 3 },
stateChanges: {},
},
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
};
describe("buildTraceFrames", () => {
it("maps node ids correctly", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.frames.map((f) => f.nodeId)).toEqual([
"start",
"review",
"create_issues",
]);
});
it("maps step types correctly", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.frames.map((f) => f.stepType)).toEqual(["use", "interrupt", "use"]);
});
it("maps outcomes correctly", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.frames.map((f) => f.outcome)).toEqual(["ok", "submitted", "ok"]);
});
it("produces concise input summaries", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.frames[1]!.inputSummary).toContain("report");
});
it("produces concise output summaries", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.frames[2]!.outputSummary).toContain("issues_created");
});
it("preserves original trace page immutability", () => {
const original = structuredClone(sampleTracePage);
buildTraceFrames(sampleTracePage);
expect(sampleTracePage).toEqual(original);
});
it("handles empty trace page", () => {
const result = buildTraceFrames({
frames: [],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
});
expect(result.frames).toEqual([]);
});
it("includes state change count", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.frames[1]!.stateChangeCount).toBe(1);
});
it("returns pagination info", () => {
const result = buildTraceFrames(sampleTracePage);
expect(result.traceStart).toBe(0);
expect(result.traceLimit).toBe(50);
expect(result.traceTruncated).toBe(false);
});
});
@@ -0,0 +1,62 @@
export type TraceFrameView = {
readonly nodeId: string;
readonly stepType: string;
readonly outcome: string;
readonly inputSummary: string;
readonly outputSummary: string;
readonly stateChangeCount: number;
readonly raw: Record<string, unknown>;
};
type TraceFrame = {
readonly nodeId: string;
readonly stepType: string;
readonly resolvedInput: Record<string, unknown>;
readonly outcome: string;
readonly output: Record<string, unknown>;
readonly stateChanges: Record<string, unknown>;
};
type TracePage = {
readonly frames: ReadonlyArray<TraceFrame>;
readonly traceStart: number;
readonly traceLimit: number;
readonly traceTruncated: boolean;
};
type TraceFramesResult = {
readonly frames: ReadonlyArray<TraceFrameView>;
readonly traceStart: number;
readonly traceLimit: number;
readonly traceTruncated: boolean;
};
const summarizeObject = (obj: Record<string, unknown>, maxKeys = 3): string => {
const keys = Object.keys(obj);
if (keys.length === 0) return "{}";
const displayed = keys.slice(0, maxKeys);
const parts = displayed.map((k) => `${k}: ${typeof obj[k]}`);
if (keys.length > maxKeys) {
parts.push(`+${keys.length - maxKeys} more`);
}
return `{ ${parts.join(", ")} }`;
};
export const buildTraceFrames = (page: TracePage): TraceFramesResult => {
const frames: TraceFrameView[] = page.frames.map((frame) => ({
nodeId: frame.nodeId,
stepType: frame.stepType,
outcome: frame.outcome,
inputSummary: summarizeObject(frame.resolvedInput),
outputSummary: summarizeObject(frame.output),
stateChangeCount: Object.keys(frame.stateChanges).length,
raw: frame as unknown as Record<string, unknown>,
}));
return {
frames,
traceStart: page.traceStart,
traceLimit: page.traceLimit,
traceTruncated: page.traceTruncated,
};
};
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { WorkflowGraph } from "./WorkflowGraph.js";
import type { WorkflowGraphModel } from "./graph-model.js";
class MockResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
beforeAll(() => {
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
globalThis.DOMRect = {
fromRect: () => ({
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON() {},
}),
} as unknown as typeof DOMRect;
});
afterAll(() => {
delete (globalThis as Record<string, unknown>).ResizeObserver;
delete (globalThis as Record<string, unknown>).DOMRect;
});
const mockModel: WorkflowGraphModel = {
nodes: [
{
id: "start",
data: {
nodeId: "start",
kind: "use",
label: "Start",
nodeRef: "workflow.start",
raw: {},
},
position: { x: 0, y: 0 },
},
{
id: "review",
data: {
nodeId: "review",
kind: "interrupt",
label: "Review",
nodeRef: null,
raw: {},
},
position: { x: 200, y: 0 },
},
{
id: "end",
data: {
nodeId: "end",
kind: "end",
label: "End",
nodeRef: null,
raw: {},
},
position: { x: 400, y: 0 },
},
],
edges: [
{ id: "e1", source: "start", target: "review", label: "ok" },
{ id: "e2", source: "review", target: "end", label: "submitted" },
],
};
const findNodeById = (container: HTMLElement, nodeId: string): HTMLElement | null =>
container.querySelector(`[data-node-id="${nodeId}"]`);
describe("WorkflowGraph", () => {
it("renders nodes and edges", () => {
const { container } = render(<WorkflowGraph model={mockModel} />);
expect(screen.getByText("Start")).toBeInTheDocument();
expect(screen.getByText("Review")).toBeInTheDocument();
expect(screen.getByText("End")).toBeInTheDocument();
expect(findNodeById(container, "start")).not.toBeNull();
expect(findNodeById(container, "review")).not.toBeNull();
expect(findNodeById(container, "end")).not.toBeNull();
});
it("calls onNodeSelect when node is clicked", () => {
const onSelect = vi.fn();
const { container } = render(<WorkflowGraph model={mockModel} onNodeSelect={onSelect} />);
const reviewNode = findNodeById(container, "review");
fireEvent.click(reviewNode!);
expect(onSelect).toHaveBeenCalledWith("review");
});
it("highlights active node when activeNodeId is provided", () => {
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId="review" />);
const reviewNode = findNodeById(container, "review");
expect(reviewNode).toHaveAttribute("data-active", "true");
});
it("does not highlight nodes when activeNodeId is null", () => {
const { container } = render(<WorkflowGraph model={mockModel} activeNodeId={null} />);
const reviewNode = findNodeById(container, "review");
expect(reviewNode).toHaveAttribute("data-active", "false");
});
it("shows empty state when no nodes", () => {
const emptyModel: WorkflowGraphModel = { nodes: [], edges: [] };
render(<WorkflowGraph model={emptyModel} />);
expect(screen.getByText(/no nodes/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,120 @@
import { useCallback, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
type NodeTypes,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import type { WorkflowGraphModel, WorkflowGraphNodeData } from "./graph-model.js";
type WorkflowGraphProps = {
readonly model: WorkflowGraphModel;
readonly activeNodeId?: string | null;
readonly onNodeSelect?: (nodeId: string) => void;
};
const nodeColor = (data: WorkflowGraphNodeData): string => {
switch (data.kind) {
case "use":
return "#3b82f6";
case "condition":
return "#f59e0b";
case "interrupt":
return "#ef4444";
case "foreach":
return "#8b5cf6";
case "join":
return "#10b981";
case "end":
return "#6b7280";
default:
return "#94a3b8";
}
};
const CustomNode = ({ data, selected }: { data: WorkflowGraphNodeData; selected: boolean }) => {
const isActive = (data as WorkflowGraphNodeData & { isActive?: boolean }).isActive;
return (
<div
role="button"
tabIndex={0}
data-active={isActive}
data-node-id={data.nodeId}
className={`graph-node graph-node--${data.kind} ${selected ? "graph-node--selected" : ""} ${isActive ? "graph-node--active" : ""}`}
style={{ borderColor: nodeColor(data) }}
>
<div className="graph-node__label">{data.label}</div>
{data.nodeRef && (
<div className="graph-node__ref">{data.nodeRef}</div>
)}
</div>
);
};
const nodeTypes: NodeTypes = {
custom: CustomNode,
};
export const WorkflowGraph = ({ model, activeNodeId = null, onNodeSelect }: WorkflowGraphProps) => {
const nodes: Node[] = useMemo(
() =>
model.nodes.map((n) => ({
id: n.id,
type: "custom",
position: n.position,
data: { ...n.data, isActive: activeNodeId === n.id },
})),
[model.nodes, activeNodeId],
);
const edges: Edge[] = useMemo(
() =>
model.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
type: "default",
})),
[model.edges],
);
const handleNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
onNodeSelect?.(node.id);
},
[onNodeSelect],
);
if (model.nodes.length === 0) {
return (
<div className="workflow-graph workflow-graph--empty">
No nodes in this workflow
</div>
);
}
return (
<div className="workflow-graph" data-testid="workflow-graph">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={handleNodeClick}
fitView
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
</div>
);
};
@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import { buildWorkflowGraph, type WorkflowGraphNodeData } from "./graph-model.js";
const samplePlan = {
nodes: [
{
id: "open",
type: "node",
node: "local.browser_click.open_click_page",
input: [],
output: [],
},
{
id: "wait",
type: "node",
node: "local.browser_click.wait_for_click",
input: [],
output: [],
},
{
id: "check",
type: "condition",
check: { op: "exists", path: "state.clicked" },
},
{
id: "ask",
type: "interrupt",
kind: "approval",
request: [],
resume: [],
outcomes: ["approved", "rejected"],
},
{
id: "__end__",
type: "end",
outcome: "ok",
},
],
edges: [
{ from: "open", outcome: "ok", to: "wait" },
{ from: "wait", outcome: "ok", to: "check" },
{ from: "check", outcome: "true", to: "ask" },
{ from: "check", outcome: "false", to: "__end__" },
{ from: "ask", outcome: "approved", to: "__end__" },
],
};
describe("buildWorkflowGraph", () => {
it("produces stable node ids from plan", () => {
const model = buildWorkflowGraph(samplePlan);
const nodeIds = model.nodes.map((n) => n.id);
expect(nodeIds).toEqual(["__end__", "ask", "check", "open", "wait"]);
});
it("maps node types correctly", () => {
const model = buildWorkflowGraph(samplePlan);
const kinds = model.nodes.map((n) => n.data.kind);
expect(kinds).toEqual(["end", "interrupt", "condition", "use", "use"]);
});
it("preserves node references", () => {
const model = buildWorkflowGraph(samplePlan);
const openNode = model.nodes.find((n) => n.id === "open");
expect(openNode?.data.nodeRef).toBe("local.browser_click.open_click_page");
});
it("creates edges from plan edges", () => {
const model = buildWorkflowGraph(samplePlan);
expect(model.edges.length).toBe(5);
});
it("labels edges with outcome names", () => {
const model = buildWorkflowGraph(samplePlan);
const okEdge = model.edges.find(
(e) => e.source === "open" && e.target === "wait",
);
expect(okEdge?.label).toBe("ok");
});
it("assigns deterministic coordinates", () => {
const model1 = buildWorkflowGraph(samplePlan);
const model2 = buildWorkflowGraph(structuredClone(samplePlan));
expect(model1.nodes.map((n) => n.position)).toEqual(
model2.nodes.map((n) => n.position),
);
});
it("does not mutate the input plan", () => {
const original = structuredClone(samplePlan);
buildWorkflowGraph(samplePlan);
expect(samplePlan).toEqual(original);
});
it("handles empty plan", () => {
const model = buildWorkflowGraph({ nodes: [], edges: [] });
expect(model.nodes).toEqual([]);
expect(model.edges).toEqual([]);
});
it("includes raw node data", () => {
const model = buildWorkflowGraph(samplePlan);
const openNode = model.nodes.find((n) => n.id === "open");
expect(openNode?.data.raw).toBeDefined();
});
});
+127
View File
@@ -0,0 +1,127 @@
import dagre from "@dagrejs/dagre";
export type WorkflowGraphNodeKind =
| "use"
| "subgraph"
| "condition"
| "interrupt"
| "foreach"
| "join"
| "end";
export type WorkflowGraphNodeData = {
readonly nodeId: string;
readonly kind: WorkflowGraphNodeKind;
readonly label: string;
readonly nodeRef: string | null;
readonly raw: Readonly<Record<string, unknown>>;
};
export type WorkflowGraphNode = {
readonly id: string;
readonly data: WorkflowGraphNodeData;
readonly position: { readonly x: number; readonly y: number };
};
export type WorkflowGraphEdge = {
readonly id: string;
readonly source: string;
readonly target: string;
readonly label: string;
};
export type WorkflowGraphModel = {
readonly nodes: ReadonlyArray<WorkflowGraphNode>;
readonly edges: ReadonlyArray<WorkflowGraphEdge>;
};
const NODE_WIDTH = 180;
const NODE_HEIGHT = 60;
const mapNodeKind = (type: string): WorkflowGraphNodeKind => {
switch (type) {
case "node":
return "use";
case "subgraph":
return "subgraph";
case "condition":
return "condition";
case "interrupt":
return "interrupt";
case "foreach":
return "foreach";
case "join":
return "join";
case "end":
return "end";
default:
return "use";
}
};
const buildLabel = (node: Record<string, unknown>): string => {
const type = node.type as string;
if (type === "end") return (node.outcome as string) ?? "End";
if (type === "condition") return "Condition";
if (type === "interrupt") return (node.kind as string) ?? "Interrupt";
if (type === "foreach") return "For Each";
if (type === "join") return "Join";
const nodeRef = node.node as string | undefined;
if (nodeRef) {
const parts = nodeRef.split(".");
return parts[parts.length - 1] ?? nodeRef;
}
return (node.id as string) ?? "Unknown";
};
export const buildWorkflowGraph = (
plan: {
nodes: ReadonlyArray<Record<string, unknown>>;
edges: ReadonlyArray<Record<string, unknown>>;
},
): WorkflowGraphModel => {
const sortedNodes = [...plan.nodes].sort((a, b) =>
String(a.id).localeCompare(String(b.id)),
);
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
for (const node of sortedNodes) {
g.setNode(String(node.id), { width: NODE_WIDTH, height: NODE_HEIGHT });
}
for (const edge of plan.edges) {
g.setEdge(String(edge.from), String(edge.to));
}
dagre.layout(g);
const nodes: WorkflowGraphNode[] = sortedNodes.map((node) => {
const id = String(node.id);
const pos = g.node(id);
return {
id,
data: {
nodeId: id,
kind: mapNodeKind(node.type as string),
label: buildLabel(node),
nodeRef: (node.node as string | null) ?? null,
raw: node as Record<string, unknown>,
},
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
};
});
let edgeIndex = 0;
const edges: WorkflowGraphEdge[] = plan.edges.map((edge) => {
const source = String(edge.from);
const target = String(edge.to);
const label = String(edge.outcome ?? "");
const id = `e-${source}-${target}-${edgeIndex++}`;
return { id, source, target, label };
});
return { nodes, edges };
};
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { LifecycleExplorer } from "./LifecycleExplorer.js";
afterEach(() => {
cleanup();
});
import type { LifecycleExplorerController } from "./useLifecycleExplorer.js";
import type { LifecycleState } from "./state.js";
const createMockController = (
overrides: Partial<LifecycleState> = {},
): LifecycleExplorerController => ({
state: {
artifactList: { phase: "idle" },
deploymentList: { phase: "idle" },
runList: { phase: "idle" },
selectedArtifactId: null,
artifactDetail: null,
selectedDeploymentId: null,
deploymentDetail: null,
deploymentValidation: null,
selectedRunId: null,
runDetail: null,
trace: null,
rawEvidence: [],
errors: [],
...overrides,
},
selectArtifact: vi.fn(),
selectDeployment: vi.fn(),
selectRun: vi.fn(),
refresh: vi.fn(),
loadMoreArtifacts: vi.fn(),
loadMoreRuns: vi.fn(),
loadTrace: vi.fn(),
});
describe("LifecycleExplorer", () => {
it("renders artifact buttons when loaded", () => {
const controller = createMockController({
artifactList: {
phase: "loaded",
value: {
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,
},
},
});
render(<LifecycleExplorer controller={controller} />);
expect(screen.getByRole("option", { name: /Report version 1/i })).toBeVisible();
});
it("renders deployment buttons when loaded", () => {
const controller = createMockController({
deploymentList: {
phase: "loaded",
value: {
items: [
{
id: "report.default",
artifactId: "report",
artifactVersion: 1,
bindingCount: 1,
driftPolicy: "block",
},
],
},
},
});
render(<LifecycleExplorer controller={controller} />);
expect(screen.getByRole("option", { name: /report.default/i })).toBeVisible();
});
it("renders run buttons when loaded", () => {
const controller = createMockController({
runList: {
phase: "loaded",
value: {
items: [
{
runId: "run_1",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
diagnosticCount: 0,
},
],
total: 1,
nextCursor: null,
},
},
});
render(<LifecycleExplorer controller={controller} />);
expect(screen.getByRole("option", { name: /run_1 interrupted/i })).toBeVisible();
});
it("calls selectArtifact when artifact is clicked", () => {
const controller = createMockController({
artifactList: {
phase: "loaded",
value: {
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,
},
},
});
render(<LifecycleExplorer controller={controller} />);
fireEvent.click(screen.getAllByRole("option", { name: /Report version 1/i })[0]!);
expect(controller.selectArtifact).toHaveBeenCalledWith("report@1");
});
it("shows empty state when no artifacts", () => {
const controller = createMockController({
artifactList: { phase: "loaded", value: { items: [], total: 0, nextCursor: null } },
});
render(<LifecycleExplorer controller={controller} />);
expect(screen.getAllByText(/no artifacts/i)[0]).toBeVisible();
});
});
@@ -0,0 +1,154 @@
import { useState, useMemo } from "react";
import type { LifecycleExplorerController } from "./useLifecycleExplorer.js";
import { RecordColumns } from "./RecordColumns.js";
import { RecordDetails } from "./RecordDetails.js";
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";
type LifecycleExplorerProps = {
readonly controller: LifecycleExplorerController;
};
type FocusMode = "lifecycle" | "graph" | "execution" | "raw";
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 graphModel = useMemo(() => {
if (!state.artifactDetail?.plan) return null;
const plan = state.artifactDetail.plan as {
nodes: ReadonlyArray<Record<string, unknown>>;
edges: ReadonlyArray<Record<string, unknown>>;
};
if (!plan.nodes || !plan.edges) return null;
return buildWorkflowGraph(plan);
}, [state.artifactDetail?.plan]);
const traceResult = useMemo(() => {
if (!state.trace) return null;
return buildTraceFrames(state.trace);
}, [state.trace]);
return (
<div className="lifecycle-explorer">
<nav className="focus-nav" aria-label="Focus modes">
<button
onClick={() => setFocusMode("lifecycle")}
className={focusMode === "lifecycle" ? "active" : ""}
>
Lifecycle
</button>
<button
onClick={() => setFocusMode("graph")}
disabled={!graphModel}
className={focusMode === "graph" ? "active" : ""}
>
Graph
</button>
<button
onClick={() => setFocusMode("execution")}
disabled={!traceResult}
className={focusMode === "execution" ? "active" : ""}
>
Execution
</button>
<button
onClick={() => setFocusMode("raw")}
className={focusMode === "raw" ? "active" : ""}
>
Raw
</button>
</nav>
{focusMode === "lifecycle" && (
<div className="lifecycle-content">
<RecordColumns
artifacts={artifacts}
deployments={deployments}
runs={runs}
selectedArtifactId={state.selectedArtifactId}
selectedDeploymentId={state.selectedDeploymentId}
selectedRunId={state.selectedRunId}
onSelectArtifact={controller.selectArtifact}
onSelectDeployment={controller.selectDeployment}
onSelectRun={controller.selectRun}
onLoadMoreArtifacts={controller.loadMoreArtifacts}
hasMoreArtifacts={state.artifactList.phase === "loaded" && state.artifactList.value.nextCursor !== null}
onLoadMoreRuns={controller.loadMoreRuns}
hasMoreRuns={state.runList.phase === "loaded" && state.runList.value.nextCursor !== null}
/>
<RecordDetails
artifactDetail={state.artifactDetail}
deploymentDetail={state.deploymentDetail}
runDetail={state.runDetail}
/>
</div>
)}
{focusMode === "graph" && graphModel && (
<div className="graph-content">
<WorkflowGraph model={graphModel} />
</div>
)}
{focusMode === "execution" && traceResult && (
<div className="execution-content">
<ExecutionView
frames={traceResult.frames}
interrupt={state.runDetail?.interrupt ? {
kind: state.runDetail.interrupt.kind,
payload: state.runDetail.interrupt.payload,
outcomes: state.runDetail.interrupt.outcomes,
requestSchema: {},
resumeSchema: {},
typed: false,
} : null}
/>
</div>
)}
{focusMode === "raw" && (
<div className="raw-content">
<h3>Protocol Evidence</h3>
{state.rawEvidence.length === 0 ? (
<p className="empty-state">No evidence recorded yet.</p>
) : (
<ul className="evidence-list">
{state.rawEvidence.map((record) => (
<li key={record.id}>
<span className="evidence-op">{record.operation}</span>
<span className="evidence-label">{record.label}</span>
<span className="evidence-duration">{record.durationMs}ms</span>
<details>
<summary>Equivalent CLI</summary>
<pre><code>{record.equivalentCli}</code></pre>
</details>
<details>
<summary>Request</summary>
<pre><code>{JSON.stringify(record.request, null, 2)}</code></pre>
</details>
<details>
<summary>Response</summary>
<pre><code>{JSON.stringify(record.response, null, 2)}</code></pre>
</details>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,109 @@
import type { ArtifactSummary, DeploymentSummary, RunSummary } from "./models.js";
type RecordColumnsProps = {
readonly artifacts: ArtifactSummary[];
readonly deployments: DeploymentSummary[];
readonly runs: RunSummary[];
readonly selectedArtifactId: string | null;
readonly selectedDeploymentId: string | null;
readonly selectedRunId: string | null;
readonly onSelectArtifact: (artifactId: string | null) => void;
readonly onSelectDeployment: (deploymentId: string | null) => void;
readonly onSelectRun: (runId: string | null) => void;
readonly onLoadMoreArtifacts?: () => void;
readonly hasMoreArtifacts?: boolean;
readonly onLoadMoreRuns?: () => void;
readonly hasMoreRuns?: boolean;
};
export const RecordColumns = ({
artifacts,
deployments,
runs,
selectedArtifactId,
selectedDeploymentId,
selectedRunId,
onSelectArtifact,
onSelectDeployment,
onSelectRun,
onLoadMoreArtifacts,
hasMoreArtifacts,
onLoadMoreRuns,
hasMoreRuns,
}: RecordColumnsProps) => (
<div className="lifecycle-columns">
<div className="lifecycle-column">
<h3>Artifacts</h3>
{artifacts.length === 0 ? (
<p className="empty-state">No artifacts</p>
) : (
<ul role="listbox" aria-label="Artifacts">
{artifacts.map((artifact) => (
<li key={artifact.key}>
<button
role="option"
aria-selected={selectedArtifactId === artifact.key}
onClick={() => onSelectArtifact(artifact.key)}
className={selectedArtifactId === artifact.key ? "selected" : ""}
>
{artifact.displayName} version {artifact.version}
</button>
</li>
))}
</ul>
)}
{hasMoreArtifacts && onLoadMoreArtifacts && (
<button type="button" onClick={onLoadMoreArtifacts} className="load-more">
Load more artifacts
</button>
)}
</div>
<div className="lifecycle-column">
<h3>Deployments</h3>
{deployments.length === 0 ? (
<p className="empty-state">No deployments</p>
) : (
<ul role="listbox" aria-label="Deployments">
{deployments.map((deployment) => (
<li key={deployment.id}>
<button
role="option"
aria-selected={selectedDeploymentId === deployment.id}
onClick={() => onSelectDeployment(deployment.id)}
className={selectedDeploymentId === deployment.id ? "selected" : ""}
>
{deployment.id}
</button>
</li>
))}
</ul>
)}
</div>
<div className="lifecycle-column">
<h3>Runs</h3>
{runs.length === 0 ? (
<p className="empty-state">No runs</p>
) : (
<ul role="listbox" aria-label="Runs">
{runs.map((run) => (
<li key={run.runId}>
<button
role="option"
aria-selected={selectedRunId === run.runId}
onClick={() => onSelectRun(run.runId)}
className={selectedRunId === run.runId ? "selected" : ""}
>
{run.runId} {run.status}
</button>
</li>
))}
</ul>
)}
{hasMoreRuns && onLoadMoreRuns && (
<button type="button" onClick={onLoadMoreRuns} className="load-more">
Load more runs
</button>
)}
</div>
</div>
);
@@ -0,0 +1,79 @@
import type { ArtifactDetail, DeploymentDetail, RunDetail } from "./models.js";
type RecordDetailsProps = {
readonly artifactDetail: ArtifactDetail | null;
readonly deploymentDetail: DeploymentDetail | null;
readonly runDetail: RunDetail | null;
};
export const RecordDetails = ({
artifactDetail,
deploymentDetail,
runDetail,
}: RecordDetailsProps) => (
<div className="record-details">
{artifactDetail && (
<section aria-label="Artifact details">
<h3>Artifact</h3>
<dl>
<dt>Name</dt>
<dd>{artifactDetail.title}</dd>
<dt>ID</dt>
<dd>{artifactDetail.artifactId}</dd>
<dt>Version</dt>
<dd>{artifactDetail.version}</dd>
<dt>Kind</dt>
<dd>{artifactDetail.kind}</dd>
<dt>Outcomes</dt>
<dd>{artifactDetail.outcomes.join(", ")}</dd>
</dl>
</section>
)}
{deploymentDetail && (
<section aria-label="Deployment details">
<h3>Deployment</h3>
<dl>
<dt>ID</dt>
<dd>{deploymentDetail.id}</dd>
<dt>Artifact ID</dt>
<dd>{deploymentDetail.artifactId}</dd>
<dt>Artifact Version</dt>
<dd>{deploymentDetail.artifactVersion}</dd>
<dt>Drift Policy</dt>
<dd>{deploymentDetail.driftPolicy}</dd>
<dt>Bindings</dt>
<dd>{deploymentDetail.bindings.length}</dd>
</dl>
</section>
)}
{runDetail && (
<section aria-label="Run details">
<h3>Run</h3>
<dl>
<dt>ID</dt>
<dd>{runDetail.runId}</dd>
<dt>Deployment</dt>
<dd>{runDetail.deploymentId}</dd>
<dt>Status</dt>
<dd>{runDetail.status}</dd>
<dt>Resume Readiness</dt>
<dd>{runDetail.resumeReadiness}</dd>
</dl>
{runDetail.interrupt && (
<div className="interrupt-details">
<h4>Interrupt</h4>
<dl>
<dt>Kind</dt>
<dd>{runDetail.interrupt.kind}</dd>
<dt>Outcomes</dt>
<dd>{runDetail.interrupt.outcomes.join(", ")}</dd>
</dl>
</div>
)}
</section>
)}
{!artifactDetail && !deploymentDetail && !runDetail && (
<p className="empty-state">Select a record to view details</p>
)}
</div>
);
@@ -0,0 +1,192 @@
import { describe, it, expect } from "vitest";
import {
decodeArtifactList,
decodeArtifactDetail,
decodeDeploymentList,
decodeDeploymentDetail,
decodeDeploymentValidation,
decodeRunList,
decodeRunDetail,
decodeTracePage,
} from "./models.js";
describe("decodeArtifactList", () => {
it("decodes an artifact list into immutable summaries", () => {
const result = decodeArtifactList({
items: [
{
key: "report@1",
artifactId: "report",
version: 1,
kind: "workflow",
displayName: "Report",
description: null,
outcomes: ["ok"],
requiredSources: ["local.report"],
diagnosticCount: 0,
},
],
nextCursor: null,
total: 1,
});
expect(result.items[0]?.key).toBe("report@1");
expect(result.items[0]?.artifactId).toBe("report");
expect(result.items[0]?.version).toBe(1);
});
it("handles empty list", () => {
const result = decodeArtifactList({
items: [],
nextCursor: null,
total: 0,
});
expect(result.items).toEqual([]);
expect(result.total).toBe(0);
});
});
describe("decodeArtifactDetail", () => {
it("decodes an artifact detail with plan", () => {
const result = decodeArtifactDetail({
artifactId: "report",
version: 1,
title: "Report",
kind: "workflow",
description: null,
outcomes: ["ok"],
plan: { nodes: [], edges: [] },
requiredCapabilities: [],
workflowDependencies: {},
createdFromCatalogVersion: null,
});
expect(result.artifactId).toBe("report");
expect(result.title).toBe("Report");
expect(result.plan).toEqual({ nodes: [], edges: [] });
});
});
describe("decodeDeploymentList", () => {
it("decodes a deployment list", () => {
const result = decodeDeploymentList({
items: [
{
id: "report.default",
artifactId: "report",
artifactVersion: 1,
bindingCount: 1,
driftPolicy: "block",
},
],
});
expect(result.items[0]?.id).toBe("report.default");
});
});
describe("decodeDeploymentDetail", () => {
it("decodes a deployment detail", () => {
const result = decodeDeploymentDetail({
id: "report.default",
artifactId: "report",
artifactVersion: 1,
bindings: [{ logicalSource: "local.report", concreteSource: "report" }],
driftPolicy: "block",
});
expect(result.id).toBe("report.default");
expect(result.bindings).toHaveLength(1);
});
});
describe("decodeDeploymentValidation", () => {
it("decodes a deployment validation result", () => {
const result = decodeDeploymentValidation({
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "runnable",
diagnostics: [],
nextActions: {
canContinue: true,
canSaveNow: null,
recommendedNextTool: null,
reason: "deployment is valid",
patchExamples: [],
warnings: [],
},
});
expect(result.status).toBe("runnable");
expect(result.nextActions.canContinue).toBe(true);
});
});
describe("decodeRunList", () => {
it("decodes a run list", () => {
const result = decodeRunList({
items: [
{
runId: "run_1",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
diagnosticCount: 0,
},
],
nextCursor: null,
total: 1,
});
expect(result.items[0]?.runId).toBe("run_1");
expect(result.items[0]?.status).toBe("interrupted");
});
});
describe("decodeRunDetail", () => {
it("decodes a run detail with interrupt", () => {
const result = decodeRunDetail({
runId: "run_1",
deploymentId: "report.default",
artifactId: "report",
artifactVersion: 1,
status: "interrupted",
resumeReadiness: "ready",
interrupt: { kind: "review", payload: {}, outcomes: [] },
outcome: null,
error: null,
output: null,
diagnostics: [],
traceCount: 0,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "run is interrupted",
patchExamples: [],
warnings: [],
},
});
expect(result.interrupt?.kind).toBe("review");
expect(result.traceCount).toBe(0);
});
});
describe("decodeTracePage", () => {
it("decodes a trace page", () => {
const result = decodeTracePage({
frames: [
{
nodeId: "review",
stepType: "interrupt",
outcome: "submitted",
resolvedInput: {},
output: {},
stateChanges: {},
},
],
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
});
expect(result.frames[0]?.nodeId).toBe("review");
expect(result.traceTruncated).toBe(false);
});
});
+193
View File
@@ -0,0 +1,193 @@
import * as v from "valibot";
const decode = <T>(
label: string,
schema: v.GenericSchema<unknown, T>,
value: unknown,
): T => {
const result = v.safeParse(schema, value);
if (result.success) return result.output;
throw new Error(
`${label} is malformed: ${result.issues[0]?.message ?? "unknown issue"}`,
);
};
// Artifact schemas
const ArtifactSummarySchema = v.object({
key: v.string(),
artifactId: v.string(),
version: v.number(),
kind: v.string(),
displayName: v.string(),
description: v.nullish(v.string(), null),
outcomes: v.array(v.string()),
requiredSources: v.array(v.string()),
diagnosticCount: v.number(),
});
const ArtifactListSchema = v.object({
items: v.array(ArtifactSummarySchema),
nextCursor: v.nullish(v.string(), null),
total: v.number(),
});
const ArtifactDetailSchema = v.object({
artifactId: v.string(),
version: v.number(),
title: v.string(),
kind: v.string(),
description: v.nullish(v.string(), null),
outcomes: v.array(v.string()),
plan: v.record(v.string(), v.unknown()),
requiredCapabilities: v.unknown(),
workflowDependencies: v.record(v.string(), v.number()),
createdFromCatalogVersion: v.nullish(v.string(), null),
});
// Deployment schemas
const DeploymentBindingSchema = v.object({
logicalSource: v.string(),
concreteSource: v.string(),
});
const DeploymentSummarySchema = v.object({
id: v.string(),
artifactId: v.string(),
artifactVersion: v.number(),
bindingCount: v.number(),
driftPolicy: v.string(),
});
const DeploymentListSchema = v.object({
items: v.array(DeploymentSummarySchema),
});
const DeploymentDetailSchema = v.object({
id: v.string(),
artifactId: v.string(),
artifactVersion: v.number(),
bindings: v.array(DeploymentBindingSchema),
driftPolicy: v.string(),
});
const DeploymentValidationSchema = v.object({
deploymentId: v.string(),
artifactId: v.string(),
artifactVersion: v.number(),
status: v.union([v.literal("runnable"), v.literal("unrunnable")]),
diagnostics: v.array(v.unknown()),
nextActions: v.object({
canContinue: v.boolean(),
canSaveNow: v.nullish(v.boolean(), null),
recommendedNextTool: v.nullish(v.string(), null),
reason: v.string(),
patchExamples: v.array(v.unknown()),
warnings: v.array(v.string()),
}),
});
// Run schemas
const RunSummarySchema = v.object({
runId: v.string(),
deploymentId: v.string(),
artifactId: v.string(),
artifactVersion: v.number(),
status: v.string(),
resumeReadiness: v.string(),
diagnosticCount: v.number(),
});
const RunInterruptSchema = v.object({
kind: v.string(),
payload: v.record(v.string(), v.unknown()),
outcomes: v.array(v.string()),
});
const RunListSchema = v.object({
items: v.array(RunSummarySchema),
nextCursor: v.nullish(v.string(), null),
total: v.number(),
});
const RunDetailSchema = v.object({
runId: v.string(),
deploymentId: v.string(),
artifactId: v.string(),
artifactVersion: v.number(),
status: v.string(),
resumeReadiness: v.string(),
interrupt: v.nullish(RunInterruptSchema, null),
outcome: v.nullish(v.string(), null),
error: v.nullish(v.string(), null),
output: v.nullish(v.record(v.string(), v.unknown()), null),
diagnostics: v.array(v.unknown()),
traceCount: v.number(),
nextActions: v.object({
canContinue: v.boolean(),
canSaveNow: v.nullish(v.boolean(), null),
recommendedNextTool: v.nullish(v.string(), null),
reason: v.string(),
patchExamples: v.array(v.unknown()),
warnings: v.array(v.string()),
}),
});
// Trace schemas
const TraceFrameSchema = v.object({
nodeId: v.string(),
stepType: v.string(),
outcome: v.string(),
resolvedInput: v.record(v.string(), v.unknown()),
output: v.record(v.string(), v.unknown()),
stateChanges: v.record(v.string(), v.unknown()),
});
const TracePageSchema = v.object({
frames: v.array(TraceFrameSchema),
traceStart: v.number(),
traceLimit: v.number(),
traceTruncated: v.boolean(),
});
// Exported types
export type ArtifactSummary = v.InferOutput<typeof ArtifactSummarySchema>;
export type ArtifactDetail = v.InferOutput<typeof ArtifactDetailSchema>;
export type DeploymentSummary = v.InferOutput<typeof DeploymentSummarySchema>;
export type DeploymentDetail = v.InferOutput<typeof DeploymentDetailSchema>;
export type DeploymentValidation = v.InferOutput<typeof DeploymentValidationSchema>;
export type RunSummary = v.InferOutput<typeof RunSummarySchema>;
export type RunDetail = v.InferOutput<typeof RunDetailSchema>;
export type TraceFrame = v.InferOutput<typeof TraceFrameSchema>;
export type TracePage = v.InferOutput<typeof TracePageSchema>;
// Exported decoders
export const decodeArtifactList = (value: unknown): ArtifactList =>
decode("ArtifactList", ArtifactListSchema, value);
export const decodeArtifactDetail = (value: unknown): ArtifactDetail =>
decode("ArtifactDetail", ArtifactDetailSchema, value);
export const decodeDeploymentList = (value: unknown): DeploymentList =>
decode("DeploymentList", DeploymentListSchema, value);
export const decodeDeploymentDetail = (value: unknown): DeploymentDetail =>
decode("DeploymentDetail", DeploymentDetailSchema, value);
export const decodeDeploymentValidation = (
value: unknown,
): DeploymentValidation =>
decode("DeploymentValidation", DeploymentValidationSchema, value);
export const decodeRunList = (value: unknown): RunList =>
decode("RunList", RunListSchema, value);
export const decodeRunDetail = (value: unknown): RunDetail =>
decode("RunDetail", RunDetailSchema, value);
export const decodeTracePage = (value: unknown): TracePage =>
decode("TracePage", TracePageSchema, value);
// List wrapper types
export type ArtifactList = v.InferOutput<typeof ArtifactListSchema>;
export type DeploymentList = v.InferOutput<typeof DeploymentListSchema>;
export type RunList = v.InferOutput<typeof RunListSchema>;
@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
lifecycleReducer,
initialLifecycleState,
type LifecycleState,
type LifecycleAction,
} from "./state.js";
describe("lifecycleReducer", () => {
it("selectArtifact clears deployment and run selections", () => {
const state: LifecycleState = {
...initialLifecycleState,
selectedArtifactId: "old@1",
selectedDeploymentId: "old.default",
selectedRunId: "run_1",
deploymentDetail: { id: "old.default" } as LifecycleState["deploymentDetail"],
runDetail: { runId: "run_1" } as LifecycleState["runDetail"],
trace: { frames: [], traceStart: 0, traceLimit: 50, traceTruncated: false } as LifecycleState["trace"],
};
const result = lifecycleReducer(state, {
type: "selectArtifact",
artifactId: "report@1",
});
expect(result.selectedArtifactId).toBe("report@1");
expect(result.selectedDeploymentId).toBeNull();
expect(result.selectedRunId).toBeNull();
expect(result.deploymentDetail).toBeNull();
expect(result.runDetail).toBeNull();
expect(result.trace).toBeNull();
});
it("selectDeployment clears run selection", () => {
const state: LifecycleState = {
...initialLifecycleState,
selectedArtifactId: "report@1",
selectedDeploymentId: "old.default",
selectedRunId: "run_1",
runDetail: { runId: "run_1" } as LifecycleState["runDetail"],
trace: { frames: [], traceStart: 0, traceLimit: 50, traceTruncated: false } as LifecycleState["trace"],
};
const result = lifecycleReducer(state, {
type: "selectDeployment",
deploymentId: "report.default",
});
expect(result.selectedDeploymentId).toBe("report.default");
expect(result.selectedRunId).toBeNull();
expect(result.runDetail).toBeNull();
expect(result.trace).toBeNull();
});
it("targetChanged resets to initial state", () => {
const state: LifecycleState = {
...initialLifecycleState,
selectedArtifactId: "report@1",
artifactList: { phase: "loaded", value: { items: [], total: 0, nextCursor: null } },
};
const result = lifecycleReducer(state, { type: "targetChanged" });
expect(result).toEqual(initialLifecycleState);
});
it("handles loading states", () => {
const state = initialLifecycleState;
const result = lifecycleReducer(state, {
type: "setArtifactListPhase",
phase: "loading",
});
expect(result.artifactList.phase).toBe("loading");
});
it("handles loaded states", () => {
const state = initialLifecycleState;
const result = lifecycleReducer(state, {
type: "setArtifactListPhase",
phase: "loaded",
value: { items: [], total: 0, nextCursor: null },
});
expect(result.artifactList.phase).toBe("loaded");
});
it("handles error states", () => {
const state = initialLifecycleState;
const result = lifecycleReducer(state, {
type: "setArtifactListPhase",
phase: "error",
message: "failed to load",
});
expect(result.artifactList.phase).toBe("error");
});
it("appendArtifactList merges new items with existing", () => {
const state: LifecycleState = {
...initialLifecycleState,
artifactList: {
phase: "loaded",
value: {
items: [
{ key: "report@1", artifactId: "report", version: 1, kind: "workflow", displayName: "Report", description: null, outcomes: ["ok"], requiredSources: [], diagnosticCount: 0 },
],
total: 2,
nextCursor: "cursor_1",
},
},
};
const result = lifecycleReducer(state, {
type: "appendArtifactList",
value: {
items: [
{ key: "summary@1", artifactId: "summary", version: 1, kind: "workflow", displayName: "Summary", description: null, outcomes: ["ok"], requiredSources: [], diagnosticCount: 0 },
],
total: 2,
nextCursor: null,
},
});
if (result.artifactList.phase !== "loaded") throw new Error("expected loaded");
expect(result.artifactList.value.items).toHaveLength(2);
expect(result.artifactList.value.items[0]!.artifactId).toBe("report");
expect(result.artifactList.value.items[1]!.artifactId).toBe("summary");
expect(result.artifactList.value.nextCursor).toBeNull();
});
it("appendRunList merges new items with existing", () => {
const state: LifecycleState = {
...initialLifecycleState,
runList: {
phase: "loaded",
value: {
items: [
{ runId: "run_1", deploymentId: "report.default", artifactId: "report", artifactVersion: 1, status: "interrupted", resumeReadiness: "ready", diagnosticCount: 0 },
],
total: 2,
nextCursor: "cursor_1",
},
},
};
const result = lifecycleReducer(state, {
type: "appendRunList",
value: {
items: [
{ runId: "run_2", deploymentId: "report.default", artifactId: "report", artifactVersion: 1, status: "completed", resumeReadiness: "none", diagnosticCount: 0 },
],
total: 2,
nextCursor: null,
},
});
if (result.runList.phase !== "loaded") throw new Error("expected loaded");
expect(result.runList.value.items).toHaveLength(2);
expect(result.runList.value.items[0]!.runId).toBe("run_1");
expect(result.runList.value.items[1]!.runId).toBe("run_2");
expect(result.runList.value.nextCursor).toBeNull();
});
it("appendArtifactList initializes from idle state", () => {
const result = lifecycleReducer(initialLifecycleState, {
type: "appendArtifactList",
value: {
items: [
{ key: "report@1", artifactId: "report", version: 1, kind: "workflow", displayName: "Report", description: null, outcomes: ["ok"], requiredSources: [], diagnosticCount: 0 },
],
total: 1,
nextCursor: null,
},
});
if (result.artifactList.phase !== "loaded") throw new Error("expected loaded");
expect(result.artifactList.value.items).toHaveLength(1);
});
});
+242
View File
@@ -0,0 +1,242 @@
import type {
ArtifactList,
ArtifactDetail,
DeploymentList,
DeploymentDetail,
DeploymentValidation,
RunList,
RunDetail,
TracePage,
} from "./models.js";
export type LoadState<T> =
| { readonly phase: "idle" }
| { readonly phase: "loading"; readonly previous: T | null }
| { readonly phase: "loaded"; readonly value: T }
| { readonly phase: "error"; readonly message: string; readonly previous: T | null };
export type EvidenceRecord = {
readonly id: string;
readonly operation: string;
readonly label: string;
readonly equivalentCli: string;
readonly request: unknown;
readonly response: unknown;
readonly durationMs: number;
};
export type LifecycleError = {
readonly operation: string;
readonly message: string;
readonly timestamp: number;
};
export type LifecycleState = {
readonly artifactList: LoadState<ArtifactList>;
readonly deploymentList: LoadState<DeploymentList>;
readonly runList: LoadState<RunList>;
readonly selectedArtifactId: string | null;
readonly artifactDetail: ArtifactDetail | null;
readonly selectedDeploymentId: string | null;
readonly deploymentDetail: DeploymentDetail | null;
readonly deploymentValidation: DeploymentValidation | null;
readonly selectedRunId: string | null;
readonly runDetail: RunDetail | null;
readonly trace: TracePage | null;
readonly rawEvidence: ReadonlyArray<EvidenceRecord>;
readonly errors: ReadonlyArray<LifecycleError>;
};
export const initialLifecycleState: LifecycleState = {
artifactList: { phase: "idle" },
deploymentList: { phase: "idle" },
runList: { phase: "idle" },
selectedArtifactId: null,
artifactDetail: null,
selectedDeploymentId: null,
deploymentDetail: null,
deploymentValidation: null,
selectedRunId: null,
runDetail: null,
trace: null,
rawEvidence: [],
errors: [],
};
export type LifecycleAction =
| { readonly type: "targetChanged" }
| { readonly type: "selectArtifact"; readonly artifactId: string | null }
| { readonly type: "selectDeployment"; readonly deploymentId: string | null }
| { readonly type: "selectRun"; readonly runId: string | null }
| { readonly type: "setArtifactListPhase"; readonly phase: "idle" | "loading" | "error"; readonly message?: string }
| { readonly type: "setArtifactListPhase"; readonly phase: "loaded"; readonly value: ArtifactList }
| { readonly type: "setDeploymentListPhase"; readonly phase: "idle" | "loading" | "error"; readonly message?: string }
| { readonly type: "setDeploymentListPhase"; readonly phase: "loaded"; readonly value: DeploymentList }
| { readonly type: "setRunListPhase"; readonly phase: "idle" | "loading" | "error"; readonly message?: string }
| { readonly type: "setRunListPhase"; readonly phase: "loaded"; readonly value: RunList }
| { readonly type: "appendArtifactList"; readonly value: ArtifactList }
| { readonly type: "appendRunList"; readonly value: RunList }
| { readonly type: "setArtifactDetail"; readonly detail: ArtifactDetail | null }
| { readonly type: "setDeploymentDetail"; readonly detail: DeploymentDetail | null }
| { readonly type: "setDeploymentValidation"; readonly validation: DeploymentValidation | null }
| { readonly type: "setRunDetail"; readonly detail: RunDetail | null }
| { readonly type: "setTrace"; readonly trace: TracePage | null }
| { readonly type: "setRawEvidence"; readonly evidence: ReadonlyArray<EvidenceRecord> }
| { readonly type: "pushError"; readonly error: LifecycleError };
const setLoadPhase = <T>(
current: LoadState<T>,
action: { phase: string; value?: T; message?: string },
): LoadState<T> => {
switch (action.phase) {
case "idle":
return { phase: "idle" };
case "loading":
return { phase: "loading", previous: current.phase === "loaded" ? current.value : current.phase === "error" ? current.previous : null };
case "loaded":
return { phase: "loaded", value: action.value as T };
case "error":
return {
phase: "error",
message: action.message ?? "unknown error",
previous: current.phase === "loaded" ? current.value : current.phase === "error" ? current.previous : null,
};
default:
return current;
}
};
export const lifecycleReducer = (
state: LifecycleState,
action: LifecycleAction,
): LifecycleState => {
switch (action.type) {
case "targetChanged":
return initialLifecycleState;
case "selectArtifact":
return {
...state,
selectedArtifactId: action.artifactId,
selectedDeploymentId: null,
selectedRunId: null,
artifactDetail: null,
deploymentDetail: null,
deploymentValidation: null,
runDetail: null,
trace: null,
};
case "selectDeployment":
return {
...state,
selectedDeploymentId: action.deploymentId,
selectedRunId: null,
deploymentDetail: null,
deploymentValidation: null,
runDetail: null,
trace: null,
};
case "selectRun":
return {
...state,
selectedRunId: action.runId,
runDetail: null,
trace: null,
};
case "setArtifactListPhase":
return {
...state,
artifactList: setLoadPhase(state.artifactList, action),
};
case "setDeploymentListPhase":
return {
...state,
deploymentList: setLoadPhase(state.deploymentList, action),
};
case "setRunListPhase":
return {
...state,
runList: setLoadPhase(state.runList, action),
};
case "appendArtifactList": {
const previous = state.artifactList.phase === "loaded" ? state.artifactList.value : null;
return {
...state,
artifactList: {
phase: "loaded",
value: {
items: [...(previous?.items ?? []), ...action.value.items],
nextCursor: action.value.nextCursor,
total: action.value.total,
},
},
};
}
case "appendRunList": {
const previous = state.runList.phase === "loaded" ? state.runList.value : null;
return {
...state,
runList: {
phase: "loaded",
value: {
items: [...(previous?.items ?? []), ...action.value.items],
nextCursor: action.value.nextCursor,
total: action.value.total,
},
},
};
}
case "setArtifactDetail":
return {
...state,
artifactDetail: action.detail,
};
case "setDeploymentDetail":
return {
...state,
deploymentDetail: action.detail,
};
case "setDeploymentValidation":
return {
...state,
deploymentValidation: action.validation,
};
case "setRunDetail":
return {
...state,
runDetail: action.detail,
};
case "setTrace":
return {
...state,
trace: action.trace,
};
case "setRawEvidence":
return {
...state,
rawEvidence: action.evidence,
};
case "pushError":
return {
...state,
errors: [...state.errors, action.error],
};
default:
return state;
}
};
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useLifecycleExplorer } from "./useLifecycleExplorer.js";
const mockCallOperation = vi.fn();
vi.mock("../connection/api.js", () => ({
callOperation: (...args: unknown[]) => mockCallOperation(...args),
}));
beforeEach(() => {
mockCallOperation.mockReset();
});
describe("useLifecycleExplorer", () => {
it("loads artifact, deployment, and run lists on target change", async () => {
mockCallOperation.mockResolvedValue({
ok: true,
operation: "workflow.artifacts.list",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
});
const recordEvidence = vi.fn();
const { result } = renderHook(() =>
useLifecycleExplorer("http://127.0.0.1:8000/rpc", recordEvidence),
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(mockCallOperation).toHaveBeenCalledWith(
"workflow.artifacts.list",
"http://127.0.0.1:8000/rpc",
expect.objectContaining({ limit: 50 }),
);
});
it("selects an artifact and requests inspect", async () => {
mockCallOperation.mockImplementation(async (operation: string) => {
if (operation === "workflow.artifacts.inspect") {
return {
ok: true,
operation: "workflow.artifacts.inspect",
interpreted: {
artifactId: "report",
version: 1,
title: "Report",
kind: "workflow",
description: null,
outcomes: ["ok"],
plan: { nodes: [], edges: [] },
requiredCapabilities: [],
workflowDependencies: {},
createdFromCatalogVersion: null,
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact inspect report --version 1",
durationMs: 5,
};
}
return {
ok: true,
operation,
interpreted: {},
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 () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
await act(async () => {
result.current.selectArtifact("report@1");
});
expect(mockCallOperation).toHaveBeenCalledWith(
"workflow.artifacts.inspect",
"http://127.0.0.1:8000/rpc",
expect.objectContaining({ artifact_id: "report", version: 1 }),
);
});
it("keeps deployment inspect and validation results from the same selection", async () => {
mockCallOperation.mockImplementation(async (operation: string) => {
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.deployments.validate") {
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,
};
}
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");
});
await waitFor(() => {
expect(result.current.state.deploymentDetail?.id).toBe("report.default");
expect(result.current.state.deploymentValidation?.status).toBe("runnable");
});
});
it("ignores stale responses after target change", async () => {
let callCount = 0;
mockCallOperation.mockImplementation(async () => {
callCount++;
if (callCount === 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return {
ok: true,
operation: "workflow.artifacts.list",
interpreted: { items: [], total: 0, nextCursor: null },
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf artifact list",
durationMs: 5,
};
});
const recordEvidence = vi.fn();
const { result, rerender } = renderHook(
({ target }) => useLifecycleExplorer(target, recordEvidence),
{ initialProps: { target: "http://first-target/rpc" } },
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
rerender({ target: "http://second-target/rpc" });
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(mockCallOperation).toHaveBeenCalledWith(
"workflow.artifacts.list",
"http://second-target/rpc",
expect.anything(),
);
});
});
@@ -0,0 +1,271 @@
import { useReducer, useEffect, useRef, useCallback } from "react";
import { callOperation } from "../connection/api.js";
import type { OperationName } from "../connection/contracts.js";
import {
decodeArtifactList,
decodeArtifactDetail,
decodeDeploymentList,
decodeDeploymentDetail,
decodeDeploymentValidation,
decodeRunList,
decodeRunDetail,
decodeTracePage,
} from "./models.js";
import { lifecycleReducer, initialLifecycleState, type LifecycleState, type EvidenceRecord } from "./state.js";
export type LifecycleExplorerController = {
readonly state: LifecycleState;
readonly selectArtifact: (artifactId: string | null) => void;
readonly selectDeployment: (deploymentId: string | null) => void;
readonly selectRun: (runId: string | null) => void;
readonly refresh: () => void;
readonly loadMoreArtifacts: () => void;
readonly loadMoreRuns: () => void;
readonly loadTrace: (start: number, limit: number) => void;
};
export const useLifecycleExplorer = (
target: string | null,
recordEvidence: (record: {
id: string;
operation: string;
label: string;
equivalentCli: string;
request: unknown;
response: unknown;
durationMs: number;
}) => void,
): LifecycleExplorerController => {
const [state, dispatch] = useReducer(lifecycleReducer, initialLifecycleState);
const generationRef = useRef(0);
const inspectGenerationRef = useRef(0);
const rawEvidenceRef = useRef<ReadonlyArray<EvidenceRecord>>([]);
const evidenceSeqRef = useRef(0);
const executeOperation = useCallback(
async (
operation: OperationName,
params: unknown,
generation: number,
checkGenerationRef: React.MutableRefObject<number>,
onSuccess: (interpreted: unknown) => void,
) => {
if (!target) return;
try {
const result = await callOperation(operation, target, params);
if (generation !== checkGenerationRef.current) return;
if (result.ok) {
const seq = evidenceSeqRef.current++;
const record: EvidenceRecord = {
id: `${result.operation}-${seq}`,
operation: result.operation,
label: result.operation,
equivalentCli: result.equivalentCli,
request: result.exchange.request,
response: result.exchange.response,
durationMs: result.durationMs,
};
recordEvidence(record);
rawEvidenceRef.current = [...rawEvidenceRef.current, record];
dispatch({ type: "setRawEvidence", evidence: rawEvidenceRef.current });
try {
onSuccess(result.interpreted);
} catch (decodeError) {
dispatch({
type: "pushError",
error: {
operation: result.operation,
message: decodeError instanceof Error ? decodeError.message : String(decodeError),
timestamp: Date.now(),
},
});
}
} else {
dispatch({
type: "pushError",
error: {
operation,
message: result.error.message,
timestamp: Date.now(),
},
});
}
} catch (rpcError) {
dispatch({
type: "pushError",
error: {
operation,
message: rpcError instanceof Error ? rpcError.message : String(rpcError),
timestamp: Date.now(),
},
});
}
},
[target, recordEvidence],
);
useEffect(() => {
if (!target) return;
generationRef.current++;
const generation = generationRef.current;
rawEvidenceRef.current = [];
dispatch({ type: "targetChanged" });
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
});
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
});
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
});
}, [target, executeOperation]);
const selectArtifact = useCallback(
(artifactId: string | null) => {
dispatch({ type: "selectArtifact", artifactId });
if (!artifactId || !target) return;
inspectGenerationRef.current++;
const generation = inspectGenerationRef.current;
const [id, version] = artifactId.split("@");
executeOperation(
"workflow.artifacts.inspect",
{ artifact_id: id, version: Number(version) },
generation,
inspectGenerationRef,
(interpreted) => {
dispatch({ type: "setArtifactDetail", detail: decodeArtifactDetail(interpreted) });
},
);
},
[target, executeOperation],
);
const selectDeployment = useCallback(
(deploymentId: string | null) => {
dispatch({ type: "selectDeployment", deploymentId });
if (!deploymentId || !target) return;
inspectGenerationRef.current++;
const generation = inspectGenerationRef.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,
(interpreted) => {
dispatch({ type: "setDeploymentDetail", detail: decodeDeploymentDetail(interpreted) });
},
);
executeOperation(
"workflow.deployments.validate",
{ deployment_id: deploymentId },
generation,
inspectGenerationRef,
(interpreted) => {
dispatch({ type: "setDeploymentValidation", validation: decodeDeploymentValidation(interpreted) });
},
);
},
[target, executeOperation],
);
const selectRun = useCallback(
(runId: string | null) => {
dispatch({ type: "selectRun", runId });
if (!runId || !target) return;
inspectGenerationRef.current++;
const generation = inspectGenerationRef.current;
executeOperation(
"workflow.runs.inspect",
{ run_id: runId },
generation,
inspectGenerationRef,
(interpreted) => {
dispatch({ type: "setRunDetail", detail: decodeRunDetail(interpreted) });
},
);
},
[target, executeOperation],
);
const refresh = useCallback(() => {
if (!target) return;
generationRef.current++;
const generation = generationRef.current;
executeOperation("workflow.artifacts.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setArtifactListPhase", phase: "loaded", value: decodeArtifactList(interpreted) });
});
executeOperation("workflow.deployments.list", {}, generation, generationRef, (interpreted) => {
dispatch({ type: "setDeploymentListPhase", phase: "loaded", value: decodeDeploymentList(interpreted) });
});
executeOperation("workflow.runs.list", { limit: 50 }, generation, generationRef, (interpreted) => {
dispatch({ type: "setRunListPhase", phase: "loaded", value: decodeRunList(interpreted) });
});
}, [target, executeOperation]);
const loadMoreArtifacts = useCallback(() => {
const current = state.artifactList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
generationRef.current++;
const generation = generationRef.current;
executeOperation(
"workflow.artifacts.list",
{ cursor: current.value.nextCursor, limit: 50 },
generation,
generationRef,
(interpreted) => {
dispatch({ type: "appendArtifactList", value: decodeArtifactList(interpreted) });
},
);
}, [state.artifactList, target, executeOperation]);
const loadMoreRuns = useCallback(() => {
const current = state.runList;
if (current.phase !== "loaded" || !current.value.nextCursor || !target) return;
generationRef.current++;
const generation = generationRef.current;
executeOperation(
"workflow.runs.list",
{ cursor: current.value.nextCursor, limit: 50 },
generation,
generationRef,
(interpreted) => {
dispatch({ type: "appendRunList", value: decodeRunList(interpreted) });
},
);
}, [state.runList, target, executeOperation]);
const loadTrace = useCallback(
(start: number, limit: number) => {
if (!state.selectedRunId || !target) return;
inspectGenerationRef.current++;
const generation = inspectGenerationRef.current;
executeOperation(
"workflow.runs.trace",
{ run_id: state.selectedRunId, trace_range: { start, limit } },
generation,
inspectGenerationRef,
(interpreted) => {
dispatch({ type: "setTrace", trace: decodeTracePage(interpreted) });
},
);
},
[state.selectedRunId, target, executeOperation],
);
return {
state,
selectArtifact,
selectDeployment,
selectRun,
refresh,
loadMoreArtifacts,
loadMoreRuns,
loadTrace,
};
};