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
+33
View File
@@ -64,6 +64,21 @@ The browser communicates with Hono at `/api/connect` and `/api/rpc`. Hono
validates targets against loopback policy, executes typed JSON-RPC calls
through Effect, and returns plain JSON DTOs to the browser.
## Lifecycle Explorer
After connecting, the console displays the lifecycle explorer with three
columns: artifacts, deployments, and runs. Selecting a record loads its detail
view.
- **Artifacts**: list and inspect workflow artifacts with plan graph visualization
- **Deployments**: list and inspect deployments with validation status
- **Runs**: list and inspect runs with interrupt details, trace frames, and
execution timeline
- **Graph**: `@xyflow/react` DAG visualization of the artifact plan, powered by
`@dagrejs/dagre` layout
- **Evidence**: raw JSON-RPC request/response evidence with equivalent CLI text
- **Pagination**: Load more for artifact and run lists when cursors are available
## Security
- Only loopback targets are accepted (`127.0.0.1`, `localhost`, `[::1]`)
@@ -84,3 +99,21 @@ With the Python server running, verify in the browser:
6. `http://example.com:8765/rpc` is rejected without upstream fetch
7. Stopping the Python server produces the unreachable state while preserving
the entered URL
8. Artifact, deployment, and run lists populate in the lifecycle explorer
9. Selecting an artifact shows its plan graph and detail panel
10. Selecting a run shows trace frames and interrupt details
11. Clicking a trace frame shows resolved input and output
### LDA Report Workflow Smoke
```powershell
# Terminal 1: start the workflow server with the report example
uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765
# Terminal 2: start the console dev server
pnpm --dir web dev
```
Connect to `http://127.0.0.1:8765/rpc`. The smoke passes when artifact list,
deployment list, run list, graph visualization, trace frames, and raw evidence
are all visible.
+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,
};
};
+15 -1
View File
@@ -11,7 +11,21 @@ export {
export { normalizeLoopbackTarget } from "./target-policy.js";
export { WorkflowHealth, WorkflowSourcesList, WorkflowRpcs } from "./rpcs.js";
export {
WorkflowHealth,
WorkflowSourcesList,
WorkflowArtifactsList,
WorkflowArtifactsInspect,
WorkflowDeploymentsList,
WorkflowDeploymentsInspect,
WorkflowDeploymentsValidate,
WorkflowRunsList,
WorkflowRunsInspect,
WorkflowRunsTrace,
WorkflowRpcs,
ArtifactRefSchema,
TraceRangeSchema,
} from "./rpcs.js";
export { WorkflowRpc, makeWorkflowRpcLayer } from "./service.js";
export type { OperationExchange, WorkflowRpcError, OperationName } from "./service.js";
+253
View File
@@ -3,6 +3,21 @@ import {
WorkflowHealthResultSchema,
WorkflowSourcesListPayloadSchema,
WorkflowSourcesListResultSchema,
WorkflowArtifactsListPayloadSchema,
WorkflowArtifactsListResultSchema,
WorkflowArtifactsInspectPayloadSchema,
WorkflowArtifactsInspectResultSchema,
WorkflowDeploymentsListResultSchema,
WorkflowDeploymentsInspectPayloadSchema,
WorkflowDeploymentsInspectResultSchema,
WorkflowDeploymentsValidatePayloadSchema,
WorkflowDeploymentsValidateResultSchema,
WorkflowRunsListPayloadSchema,
WorkflowRunsListResultSchema,
WorkflowRunsInspectPayloadSchema,
WorkflowRunsInspectResultSchema,
WorkflowRunsTracePayloadSchema,
WorkflowRunsTraceResultSchema,
} from "./rpcs.js";
export type OperationMeta = {
@@ -87,6 +102,244 @@ const operationEntries: ReadonlyArray<OperationMeta> = [
};
},
},
{
method: "workflow.artifacts.list",
label: "List artifacts",
explanation: "List workflow artifacts with pagination",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowArtifactsListPayloadSchema)(
params,
{ onExcessProperty: "error" },
);
const parts = ["uv run wf artifact list"];
if (p.limit != null) parts.push(`--limit ${p.limit}`);
return parts.join(" ");
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(
WorkflowArtifactsListResultSchema,
)(result);
return {
items: decoded.nodes.map((node) => ({
key: `${node.artifact_id}@${node.version}`,
artifactId: node.artifact_id,
version: node.version,
kind: node.kind,
displayName: node.display_name,
description: node.description,
outcomes: node.outcomes,
requiredSources: node.required_sources,
diagnosticCount: node.diagnostics.length,
})),
nextCursor: decoded.next_cursor,
total: decoded.total,
};
},
},
{
method: "workflow.artifacts.inspect",
label: "Inspect artifact",
explanation: "Inspect a workflow artifact by id and version",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowArtifactsInspectPayloadSchema)(
params,
{ onExcessProperty: "error" },
);
return `uv run wf artifact inspect ${p.artifact_id} --version ${p.version}`;
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(
WorkflowArtifactsInspectResultSchema,
)(result);
return {
artifactId: decoded.id,
version: decoded.version,
title: decoded.title,
kind: decoded.kind,
description: decoded.description,
outcomes: decoded.outcomes,
plan: decoded.plan,
requiredCapabilities: decoded.required_capabilities,
workflowDependencies: decoded.workflow_dependencies,
createdFromCatalogVersion: decoded.created_from_catalog_version,
};
},
},
{
method: "workflow.deployments.list",
label: "List deployments",
explanation: "List workflow deployments",
idempotency: "read",
equivalentCli: () => "uv run wf deploy list",
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(
WorkflowDeploymentsListResultSchema,
)(result);
return {
items: decoded.deployments.map((d) => ({
id: d.id,
artifactId: d.artifact_id,
artifactVersion: d.artifact_version,
bindingCount: d.binding_count,
driftPolicy: d.drift_policy,
})),
};
},
},
{
method: "workflow.deployments.inspect",
label: "Inspect deployment",
explanation: "Inspect a workflow deployment by id",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(
WorkflowDeploymentsInspectPayloadSchema,
)(params, { onExcessProperty: "error" });
return `uv run wf deploy inspect ${p.deployment_id}`;
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(
WorkflowDeploymentsInspectResultSchema,
)(result);
return {
id: decoded.id,
artifactId: decoded.artifact_id,
artifactVersion: decoded.artifact_version,
bindings: decoded.bindings.map((b) => ({
logicalSource: b.logical_source,
concreteSource: b.concrete_source,
})),
driftPolicy: decoded.drift_policy,
};
},
},
{
method: "workflow.deployments.validate",
label: "Validate deployment",
explanation: "Validate a workflow deployment",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(
WorkflowDeploymentsValidatePayloadSchema,
)(params, { onExcessProperty: "error" });
return `uv run wf deploy validate ${p.deployment_id}`;
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(
WorkflowDeploymentsValidateResultSchema,
)(result);
return {
deploymentId: decoded.deployment_id,
artifactId: decoded.artifact_id,
artifactVersion: decoded.artifact_version,
status: decoded.status,
diagnostics: decoded.diagnostics,
nextActions: decoded.next_actions,
};
},
},
{
method: "workflow.runs.list",
label: "List runs",
explanation: "List workflow runs with pagination",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowRunsListPayloadSchema)(
params,
{ onExcessProperty: "error" },
);
const parts = ["uv run wf run list"];
if (p.limit != null) parts.push(`--limit ${p.limit}`);
return parts.join(" ");
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsListResultSchema)(
result,
);
return {
items: decoded.runs.map((run) => ({
runId: run.run_id,
deploymentId: run.deployment_id,
artifactId: run.artifact_id,
artifactVersion: run.artifact_version,
status: run.status,
resumeReadiness: run.resume_readiness,
diagnosticCount: run.diagnostic_count,
createdAt: run.created_at,
updatedAt: run.updated_at,
})),
nextCursor: decoded.next_cursor,
total: decoded.total,
};
},
},
{
method: "workflow.runs.inspect",
label: "Inspect run",
explanation: "Inspect a workflow run by id",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowRunsInspectPayloadSchema)(
params,
{ onExcessProperty: "error" },
);
return `uv run wf run inspect ${p.run_id}`;
},
interpret: (result) => {
const decoded = Schema.decodeUnknownSync(WorkflowRunsInspectResultSchema)(
result,
);
return {
runId: decoded.run_id,
deploymentId: decoded.deployment_id,
artifactId: decoded.artifact_id,
artifactVersion: decoded.artifact_version,
status: decoded.status,
resumeReadiness: decoded.resume_readiness,
interrupt: decoded.interrupt,
outcome: decoded.outcome,
error: decoded.error,
output: decoded.output,
diagnostics: decoded.diagnostics,
traceCount: decoded.trace_count,
nextActions: decoded.next_actions,
};
},
},
{
method: "workflow.runs.trace",
label: "Read run trace",
explanation: "Read trace frames for a workflow run",
idempotency: "read",
equivalentCli: (params) => {
const p = Schema.decodeUnknownSync(WorkflowRunsTracePayloadSchema)(
params,
{ onExcessProperty: "error" },
);
return `uv run wf run trace ${p.run_id} --from ${p.trace_range.start} --limit ${p.trace_range.limit}`;
},
interpret: (result) => {
const r = result as Record<string, unknown>;
const trace = (r.trace as ReadonlyArray<Record<string, unknown>> ?? []).map((entry) => ({
nodeId: entry.node_id,
stepType: entry.step_type,
outcome: entry.outcome,
resolvedInput: entry.resolved_input,
output: entry.output,
stateChanges: entry.state_changes,
}));
return {
runId: r.run_id,
status: r.status,
trace,
traceStart: r.trace_start,
traceLimit: r.trace_limit,
traceTruncated: r.trace_truncated,
};
},
},
];
const registry: ReadonlyMap<string, OperationMeta> = new Map(
+268 -1
View File
@@ -6,6 +6,26 @@ const NonNegativeIntegerSchema = Schema.Number.pipe(
Schema.between(0, Number.MAX_SAFE_INTEGER),
);
const PositiveIntegerSchema = Schema.Number.pipe(
Schema.int(),
Schema.between(1, Number.MAX_SAFE_INTEGER),
);
const JsonObjectSchema = Schema.Record({
key: Schema.String,
value: Schema.Unknown,
});
export const ArtifactRefSchema = Schema.Struct({
artifact_id: Schema.String,
version: PositiveIntegerSchema,
});
export const TraceRangeSchema = Schema.Struct({
start: NonNegativeIntegerSchema,
limit: PositiveIntegerSchema,
});
export const SourceSummarySchema = Schema.Struct({
id: Schema.String,
kind: Schema.String,
@@ -48,4 +68,251 @@ export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
error: Schema.Never,
});
export const WorkflowRpcs = RpcGroup.make(WorkflowHealth, WorkflowSourcesList);
// Artifacts
export const WorkflowArtifactsListPayloadSchema = Schema.Struct({
query: Schema.optional(Schema.String),
kind: Schema.optional(Schema.Literal("workflow", "wrapper")),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(PositiveIntegerSchema),
});
const ArtifactNodeSchema = Schema.Struct({
name: Schema.String,
artifact_id: Schema.String,
version: PositiveIntegerSchema,
kind: Schema.String,
display_name: Schema.String,
description: Schema.NullOr(Schema.String),
outcomes: Schema.Array(Schema.String),
input_schema: JsonObjectSchema,
output_schema: JsonObjectSchema,
required_sources: Schema.Array(Schema.String),
diagnostics: Schema.Array(Schema.Unknown),
});
export const WorkflowArtifactsListResultSchema = Schema.Struct({
nodes: Schema.Array(ArtifactNodeSchema),
total: NonNegativeIntegerSchema,
cursor: Schema.NullOr(Schema.String),
next_cursor: Schema.NullOr(Schema.String),
limit: Schema.optional(PositiveIntegerSchema),
});
export const WorkflowArtifactsList = Rpc.make("workflow.artifacts.list", {
payload: WorkflowArtifactsListPayloadSchema,
success: WorkflowArtifactsListResultSchema,
error: Schema.Never,
});
export const WorkflowArtifactsInspectPayloadSchema = Schema.Struct({
artifact_id: Schema.String,
version: PositiveIntegerSchema,
});
export const WorkflowArtifactsInspectResultSchema = Schema.Struct({
id: Schema.String,
version: PositiveIntegerSchema,
title: Schema.String,
kind: Schema.String,
description: Schema.NullOr(Schema.String),
outcomes: Schema.Array(Schema.String),
input_schema: JsonObjectSchema,
output_schema: JsonObjectSchema,
plan: JsonObjectSchema,
required_capabilities: Schema.Unknown,
workflow_dependencies: Schema.Record({ key: Schema.String, value: Schema.Number }),
created_from_catalog_version: Schema.NullOr(Schema.String),
});
export const WorkflowArtifactsInspect = Rpc.make("workflow.artifacts.inspect", {
payload: WorkflowArtifactsInspectPayloadSchema,
success: WorkflowArtifactsInspectResultSchema,
error: Schema.Never,
});
// Deployments
export const WorkflowDeploymentsListPayloadSchema = Schema.Struct({});
const DeploymentNodeSchema = Schema.Struct({
id: Schema.String,
artifact_id: Schema.String,
artifact_version: PositiveIntegerSchema,
binding_count: NonNegativeIntegerSchema,
drift_policy: Schema.String,
});
export const WorkflowDeploymentsListResultSchema = Schema.Struct({
deployments: Schema.Array(DeploymentNodeSchema),
});
export const WorkflowDeploymentsList = Rpc.make("workflow.deployments.list", {
payload: WorkflowDeploymentsListPayloadSchema,
success: WorkflowDeploymentsListResultSchema,
error: Schema.Never,
});
export const WorkflowDeploymentsInspectPayloadSchema = Schema.Struct({
deployment_id: Schema.String,
});
const DeploymentBindingSchema = Schema.Struct({
logical_source: Schema.String,
concrete_source: Schema.String,
});
export const WorkflowDeploymentsInspectResultSchema = Schema.Struct({
id: Schema.String,
artifact_id: Schema.String,
artifact_version: PositiveIntegerSchema,
bindings: Schema.Array(DeploymentBindingSchema),
drift_policy: Schema.String,
});
export const WorkflowDeploymentsInspect = Rpc.make("workflow.deployments.inspect", {
payload: WorkflowDeploymentsInspectPayloadSchema,
success: WorkflowDeploymentsInspectResultSchema,
error: Schema.Never,
});
export const WorkflowDeploymentsValidatePayloadSchema = Schema.Struct({
deployment_id: Schema.String,
live_check: Schema.optional(Schema.Boolean),
});
export const WorkflowDeploymentsValidateResultSchema = Schema.Struct({
deployment_id: Schema.String,
artifact_id: Schema.String,
artifact_version: PositiveIntegerSchema,
status: Schema.Literal("runnable", "unrunnable"),
diagnostics: Schema.Array(Schema.Unknown),
next_actions: Schema.Struct({
can_continue: Schema.Boolean,
can_save_now: Schema.NullOr(Schema.Boolean),
recommended_next_tool: Schema.NullOr(Schema.String),
reason: Schema.String,
patch_examples: Schema.Array(Schema.Unknown),
warnings: Schema.Array(Schema.String),
}),
});
export const WorkflowDeploymentsValidate = Rpc.make("workflow.deployments.validate", {
payload: WorkflowDeploymentsValidatePayloadSchema,
success: WorkflowDeploymentsValidateResultSchema,
error: Schema.Never,
});
// Runs
export const WorkflowRunsListPayloadSchema = Schema.Struct({
status: Schema.optional(Schema.Literal("completed", "failed", "interrupted")),
cursor: Schema.optional(Schema.String),
limit: Schema.optional(PositiveIntegerSchema),
});
const RunNodeSchema = Schema.Struct({
run_id: Schema.String,
deployment_id: Schema.String,
artifact_id: Schema.String,
artifact_version: PositiveIntegerSchema,
status: Schema.String,
resume_readiness: Schema.String,
diagnostic_count: NonNegativeIntegerSchema,
created_at: Schema.String,
updated_at: Schema.String,
});
export const WorkflowRunsListResultSchema = Schema.Struct({
runs: Schema.Array(RunNodeSchema),
total: NonNegativeIntegerSchema,
cursor: Schema.NullOr(Schema.String),
next_cursor: Schema.NullOr(Schema.String),
limit: PositiveIntegerSchema,
});
export const WorkflowRunsList = Rpc.make("workflow.runs.list", {
payload: WorkflowRunsListPayloadSchema,
success: WorkflowRunsListResultSchema,
error: Schema.Never,
});
export const WorkflowRunsInspectPayloadSchema = Schema.Struct({
run_id: Schema.String,
});
const RunInterruptSchema = Schema.Struct({
kind: Schema.String,
payload: JsonObjectSchema,
outcomes: Schema.Array(Schema.String),
});
const RunNextActionsSchema = Schema.Struct({
can_continue: Schema.Boolean,
can_save_now: Schema.NullOr(Schema.Boolean),
recommended_next_tool: Schema.NullOr(Schema.String),
reason: Schema.String,
patch_examples: Schema.Array(Schema.Unknown),
warnings: Schema.Array(Schema.String),
});
export const WorkflowRunsInspectResultSchema = Schema.Struct({
run_id: Schema.String,
deployment_id: Schema.String,
artifact_id: Schema.String,
artifact_version: PositiveIntegerSchema,
status: Schema.String,
resume_readiness: Schema.String,
interrupt: Schema.NullOr(RunInterruptSchema),
outcome: Schema.NullOr(Schema.String),
error: Schema.NullOr(Schema.String),
output: Schema.NullOr(JsonObjectSchema),
diagnostics: Schema.Array(Schema.Unknown),
trace_count: NonNegativeIntegerSchema,
next_actions: RunNextActionsSchema,
});
export const WorkflowRunsInspect = Rpc.make("workflow.runs.inspect", {
payload: WorkflowRunsInspectPayloadSchema,
success: WorkflowRunsInspectResultSchema,
error: Schema.Never,
});
export const WorkflowRunsTracePayloadSchema = Schema.Struct({
run_id: Schema.String,
trace_range: TraceRangeSchema,
});
const TraceFrameSchema = Schema.Struct({
node_id: Schema.String,
step_type: Schema.String,
resolved_input: JsonObjectSchema,
outcome: Schema.String,
output: JsonObjectSchema,
state_changes: JsonObjectSchema,
});
export const WorkflowRunsTraceResultSchema = Schema.Struct({
run_id: Schema.String,
status: Schema.String,
trace: Schema.Array(TraceFrameSchema),
trace_start: NonNegativeIntegerSchema,
trace_limit: PositiveIntegerSchema,
trace_truncated: Schema.Boolean,
});
export const WorkflowRunsTrace = Rpc.make("workflow.runs.trace", {
payload: WorkflowRunsTracePayloadSchema,
success: WorkflowRunsTraceResultSchema,
error: Schema.Never,
});
export const WorkflowRpcs = RpcGroup.make(
WorkflowHealth,
WorkflowSourcesList,
WorkflowArtifactsList,
WorkflowArtifactsInspect,
WorkflowDeploymentsList,
WorkflowDeploymentsInspect,
WorkflowDeploymentsValidate,
WorkflowRunsList,
WorkflowRunsInspect,
WorkflowRunsTrace,
);
+186
View File
@@ -73,6 +73,167 @@ const runEither = (
.pipe(Effect.either);
}).pipe(Effect.provide(makeWorkflowRpcLayer(options)), Effect.runPromise);
const lifecycleCases = [
{
operation: "workflow.artifacts.list" as const,
params: { limit: 50 },
result: {
nodes: [
{
name: "workflow.report@1",
artifact_id: "report",
version: 1,
kind: "workflow",
display_name: "Report",
description: null,
outcomes: ["ok"],
input_schema: { type: "object" },
output_schema: { type: "object" },
required_sources: ["local.report"],
diagnostics: [],
},
],
total: 1,
cursor: null,
next_cursor: null,
limit: 50,
},
},
{
operation: "workflow.artifacts.inspect" as const,
params: { artifact_id: "report", version: 1 },
result: {
id: "report",
version: 1,
title: "Report",
kind: "workflow",
description: null,
outcomes: ["ok"],
input_schema: { type: "object" },
output_schema: { type: "object" },
plan: { nodes: [], edges: [] },
required_capabilities: [],
workflow_dependencies: {},
created_from_catalog_version: null,
},
},
{
operation: "workflow.deployments.list" as const,
params: {},
result: {
deployments: [
{
id: "report.default",
artifact_id: "report",
artifact_version: 1,
binding_count: 1,
drift_policy: "block",
},
],
},
},
{
operation: "workflow.deployments.inspect" as const,
params: { deployment_id: "report.default" },
result: {
id: "report.default",
artifact_id: "report",
artifact_version: 1,
bindings: [{ logical_source: "local.report", concrete_source: "report" }],
drift_policy: "block",
},
},
{
operation: "workflow.deployments.validate" as const,
params: { deployment_id: "report.default" },
result: {
deployment_id: "report.default",
artifact_id: "report",
artifact_version: 1,
status: "runnable",
diagnostics: [],
next_actions: {
can_continue: true,
can_save_now: null,
recommended_next_tool: null,
reason: "deployment is valid",
patch_examples: [],
warnings: [],
},
},
},
{
operation: "workflow.runs.list" as const,
params: { limit: 50 },
result: {
runs: [
{
run_id: "run_1",
deployment_id: "report.default",
artifact_id: "report",
artifact_version: 1,
status: "interrupted",
resume_readiness: "ready",
diagnostic_count: 0,
created_at: "2026-07-02T00:00:00Z",
updated_at: "2026-07-02T00:00:01Z",
},
],
total: 1,
cursor: null,
next_cursor: null,
limit: 50,
},
},
{
operation: "workflow.runs.inspect" as const,
params: { run_id: "run_1" },
result: {
run_id: "run_1",
deployment_id: "report.default",
artifact_id: "report",
artifact_version: 1,
status: "interrupted",
resume_readiness: "ready",
interrupt: { kind: "review", payload: {}, outcomes: [] },
outcome: null,
error: null,
output: null,
diagnostics: [],
trace_count: 0,
next_actions: {
can_continue: false,
can_save_now: null,
recommended_next_tool: null,
reason: "run is interrupted",
patch_examples: [],
warnings: [],
},
},
},
{
operation: "workflow.runs.trace" as const,
params: { run_id: "run_1", trace_range: { start: 0, limit: 50 } },
result: {
run_id: "run_1",
status: "interrupted",
trace_start: 0,
trace_limit: 50,
trace_truncated: false,
trace: [
{
node_id: "review",
step_type: "interrupt",
resolved_input: { report: "..." },
outcome: "submitted",
output: {},
state_changes: {},
},
],
},
},
] as const;
describe("WorkflowRpc", () => {
it("uses @effect/rpc and returns exact raw request and response evidence", async () => {
const fetch: typeof globalThis.fetch = async (input, init) => {
@@ -251,3 +412,28 @@ describe("WorkflowRpc", () => {
expect(result.left).toBeInstanceOf(RpcProtocolError);
});
});
describe("lifecycle operations", () => {
for (const testCase of lifecycleCases) {
it(`handles ${testCase.operation} successfully`, async () => {
const fetch: typeof globalThis.fetch = async (input, init) => {
const request = await requestBody(input, init);
expect(request.method).toBe(testCase.operation);
return jsonResponse({
jsonrpc: "2.0",
id: request.id,
result: testCase.result,
});
};
const exchange = await runOperation(
{ fetch },
testCase.operation as "workflow.health" | "workflow.sources.list",
testCase.params,
);
expect(exchange.operation).toBe(testCase.operation);
expect(exchange.interpreted).toBeDefined();
});
}
});
+85 -2
View File
@@ -27,13 +27,31 @@ import {
WorkflowHealthPayloadSchema,
WorkflowRpcs,
WorkflowSourcesListPayloadSchema,
WorkflowArtifactsListPayloadSchema,
WorkflowArtifactsInspectPayloadSchema,
WorkflowDeploymentsListPayloadSchema,
WorkflowDeploymentsInspectPayloadSchema,
WorkflowDeploymentsValidatePayloadSchema,
WorkflowRunsListPayloadSchema,
WorkflowRunsInspectPayloadSchema,
WorkflowRunsTracePayloadSchema,
} from "./rpcs.js";
import { normalizeLoopbackTarget } from "./target-policy.js";
const DEFAULT_TIMEOUT_MILLISECONDS = 5_000;
const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
export type OperationName = "workflow.health" | "workflow.sources.list";
export type OperationName =
| "workflow.health"
| "workflow.sources.list"
| "workflow.artifacts.list"
| "workflow.artifacts.inspect"
| "workflow.deployments.list"
| "workflow.deployments.inspect"
| "workflow.deployments.validate"
| "workflow.runs.list"
| "workflow.runs.inspect"
| "workflow.runs.trace";
export interface WorkflowRpcOptions {
readonly fetch?: typeof globalThis.fetch;
@@ -62,7 +80,16 @@ export type WorkflowRpcError =
| RpcDecodeError;
const isOperationName = (value: string): value is OperationName =>
value === "workflow.health" || value === "workflow.sources.list";
value === "workflow.health" ||
value === "workflow.sources.list" ||
value === "workflow.artifacts.list" ||
value === "workflow.artifacts.inspect" ||
value === "workflow.deployments.list" ||
value === "workflow.deployments.inspect" ||
value === "workflow.deployments.validate" ||
value === "workflow.runs.list" ||
value === "workflow.runs.inspect" ||
value === "workflow.runs.trace";
const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({
request: evidence?.request.body ?? null,
@@ -262,6 +289,62 @@ const executeImpl =
);
return yield* client.workflow["sources.list"](payload);
}
case "workflow.artifacts.list": {
const payload = yield* decodeParams(
WorkflowArtifactsListPayloadSchema,
params,
);
return yield* client.workflow["artifacts.list"](payload);
}
case "workflow.artifacts.inspect": {
const payload = yield* decodeParams(
WorkflowArtifactsInspectPayloadSchema,
params,
);
return yield* client.workflow["artifacts.inspect"](payload);
}
case "workflow.deployments.list": {
const payload = yield* decodeParams(
WorkflowDeploymentsListPayloadSchema,
params,
);
return yield* client.workflow["deployments.list"](payload);
}
case "workflow.deployments.inspect": {
const payload = yield* decodeParams(
WorkflowDeploymentsInspectPayloadSchema,
params,
);
return yield* client.workflow["deployments.inspect"](payload);
}
case "workflow.deployments.validate": {
const payload = yield* decodeParams(
WorkflowDeploymentsValidatePayloadSchema,
params,
);
return yield* client.workflow["deployments.validate"](payload);
}
case "workflow.runs.list": {
const payload = yield* decodeParams(
WorkflowRunsListPayloadSchema,
params,
);
return yield* client.workflow["runs.list"](payload);
}
case "workflow.runs.inspect": {
const payload = yield* decodeParams(
WorkflowRunsInspectPayloadSchema,
params,
);
return yield* client.workflow["runs.inspect"](payload);
}
case "workflow.runs.trace": {
const payload = yield* decodeParams(
WorkflowRunsTracePayloadSchema,
params,
);
return yield* client.workflow["runs.trace"](payload);
}
}
}).pipe(
Effect.provide(protocolLayer),
+208
View File
@@ -17,6 +17,9 @@ importers:
apps/console:
dependencies:
'@dagrejs/dagre':
specifier: 3.0.0
version: 3.0.0
'@fontsource-variable/source-sans-3':
specifier: 5.2.9
version: 5.2.9
@@ -26,6 +29,9 @@ importers:
'@fontsource/ibm-plex-mono':
specifier: 5.2.7
version: 5.2.7
'@xyflow/react':
specifier: 12.11.1
version: 12.11.1(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: 19.2.7
version: 19.2.7
@@ -180,6 +186,12 @@ packages:
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@dagrejs/[email protected]':
resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==}
'@dagrejs/[email protected]':
resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==}
'@effect/[email protected]':
resolution: {integrity: sha512-oJm3UztdzZvK7BXkFSV3IdyGuqQrhLmViG/hMDZh99ski7aADesNuv19z4R0heH3bAXnSt/Nb8O9KJQ886C0Tg==}
peerDependencies:
@@ -561,6 +573,24 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/[email protected]':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/[email protected]':
resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
'@types/[email protected]':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/[email protected]':
resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
'@types/[email protected]':
resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
'@types/[email protected]':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
'@types/[email protected]':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -620,6 +650,22 @@ packages:
'@vitest/[email protected]':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
'@xyflow/[email protected]':
resolution: {integrity: sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==}
peerDependencies:
'@types/react': '>=17'
'@types/react-dom': '>=17'
react: '>=17'
react-dom: '>=17'
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@xyflow/[email protected]':
resolution: {integrity: sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==}
[email protected]:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -658,6 +704,9 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
[email protected]:
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
[email protected]:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
@@ -680,6 +729,44 @@ packages:
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
[email protected]:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
engines: {node: '>=12'}
peerDependencies:
d3-selection: 2 - 3
[email protected]:
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
engines: {node: '>=12'}
[email protected]:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1054,6 +1141,11 @@ packages:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
[email protected]:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
[email protected]:
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
peerDependencies:
@@ -1190,6 +1282,21 @@ packages:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
[email protected]:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
snapshots:
'@adobe/[email protected]': {}
@@ -1252,6 +1359,12 @@ snapshots:
'@csstools/[email protected]': {}
'@dagrejs/[email protected]':
dependencies:
'@dagrejs/graphlib': 4.0.1
'@dagrejs/[email protected]': {}
'@effect/[email protected]([email protected])':
dependencies:
effect: 3.21.4
@@ -1499,6 +1612,27 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/d3-selection': 3.0.11
'@types/[email protected]':
dependencies:
'@types/d3-color': 3.1.3
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/d3-selection': 3.0.11
'@types/[email protected]':
dependencies:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -1561,6 +1695,31 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xyflow/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@xyflow/system': 0.0.78
classcat: 5.0.5
react: 19.2.7
react-dom: 19.2.7([email protected])
zustand: 4.5.7(@types/[email protected])([email protected])
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/[email protected])
transitivePeerDependencies:
- immer
'@xyflow/[email protected]':
dependencies:
'@types/d3-drag': 3.0.7
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
'@types/d3-transition': 3.0.9
'@types/d3-zoom': 3.0.8
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-zoom: 3.0.0
[email protected]: {}
[email protected]: {}
@@ -1585,6 +1744,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
string-width: 7.2.0
@@ -1611,6 +1772,42 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
d3-dispatch: 3.0.1
d3-selection: 3.0.0
[email protected]: {}
[email protected]:
dependencies:
d3-color: 3.1.0
[email protected]: {}
[email protected]: {}
[email protected]([email protected]):
dependencies:
d3-color: 3.1.0
d3-dispatch: 3.0.1
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-timer: 3.0.1
[email protected]:
dependencies:
d3-dispatch: 3.0.1
d3-drag: 3.0.0
d3-interpolate: 3.0.1
d3-selection: 3.0.0
d3-transition: 3.0.1([email protected])
[email protected]:
dependencies:
whatwg-mimetype: 5.0.0
@@ -1963,6 +2160,10 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
dependencies:
react: 19.2.7
[email protected]([email protected]):
optionalDependencies:
typescript: 6.0.3
@@ -2051,3 +2252,10 @@ snapshots:
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
[email protected](@types/[email protected])([email protected]):
dependencies:
use-sync-external-store: 1.6.0([email protected])
optionalDependencies:
'@types/react': 19.2.17
react: 19.2.7