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
+4 -2
View File
@@ -49,10 +49,12 @@ Implementation order:
[`workflow console foundation`](superpowers/specs/2026-07-01-workflow-console-foundation-design.md).
Implementation:
[`workflow console foundation plan`](historical/superpowers/plans/2026-07-02-workflow-console-foundation.md).
4. Add the generic console lifecycle explorer, exercised first through the
artifact -> deployment -> run -> trace path, with interactive graph and raw
4. Completed: add the generic console lifecycle explorer, exercised first through
the artifact -> deployment -> run -> trace path, with interactive graph and raw
RPC evidence. Design:
[`workflow console lifecycle explorer`](superpowers/specs/2026-07-02-workflow-console-lifecycle-explorer.md).
Implementation:
[`workflow console lifecycle explorer plan`](historical/superpowers/plans/2026-07-02-workflow-console-lifecycle-explorer.md).
Draft workspace inspection reuses the same shell after the first vertical
path.
5. Add lifecycle autoplay, typed approval, issue-board output, and replay.
@@ -0,0 +1,797 @@
# Workflow Console Lifecycle Explorer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a generic read-only artifact -> deployment -> run -> trace explorer with an interactive workflow graph, exercised through `examples/lda_report_workflow/`.
**Architecture:** Extend the existing Effect RPC registry with public lifecycle read operations, then adapt decoded results into plain console view models. A focused React lifecycle shell owns selection and request generations; `@xyflow/react` renders workflow plans laid out by `@dagrejs/dagre`, while a separate execution adapter correlates bounded trace frames to graph nodes.
**Tech Stack:** TypeScript 6, React 19, Vite 8, Effect 3 / `@effect/rpc`, Valibot, Vitest, Testing Library, `@xyflow/react`, `@dagrejs/dagre`, Hono.
## Global Constraints
- Use only public JSON-RPC methods; never read workflow stores directly.
- Add no Python RPC methods in this slice.
- Keep the slice read-only: no draft mutation, run start/resume, autoplay, replay, agent, or presentation code.
- Decode external results before React components receive them; React consumes plain view models and does not run Effect programs.
- Preserve raw request, raw response, duration, and equivalent CLI evidence for every operation.
- Request at most 50 artifacts/runs initially and one bounded trace page of 50 frames.
- Ignore stale responses after target or selection changes.
- Use `@xyflow/react` and `@dagrejs/dagre`; do not hand-roll graph layout.
- Use `examples/lda_report_workflow/` as the reference fixture without hard-coding its ids in production components.
- Do not use `any`, unchecked TypeScript assertions, or direct mutation of decoded transport objects.
---
## File Structure
Create focused modules instead of expanding `App.tsx`:
```text
web/apps/console/src/
lifecycle/
models.ts # Valibot decoding and plain lifecycle view models
models.test.ts
state.ts # Pure selection/loading reducer
state.test.ts
useLifecycleExplorer.ts # RPC orchestration and stale-response guards
LifecycleExplorer.tsx # Focus navigation and master-detail composition
LifecycleExplorer.test.tsx
RecordColumns.tsx # Artifact/deployment/run list columns
RecordDetails.tsx # Read-only selected-record details
graph/
graph-model.ts # Raw plan -> laid-out React Flow model
graph-model.test.ts
WorkflowGraph.tsx # Canvas and node selection
WorkflowGraph.test.tsx
NodeInspector.tsx # Semantic node detail drawer
execution/
trace-model.ts # Trace decoding/correlation view model
trace-model.test.ts
ExecutionView.tsx # Run summary, interrupt, and trace timeline
ExecutionView.test.tsx
```
Modify the existing RPC package, browser contracts, App shell, styles, docs,
and package manifests only where their responsibility requires it.
### Task 1: Map Lifecycle Read Operations Through Effect RPC
**Files:**
- Modify: `web/packages/rpc/src/rpcs.ts`
- Modify: `web/packages/rpc/src/method-registry.ts`
- Modify: `web/packages/rpc/src/service.ts`
- Modify: `web/packages/rpc/src/index.ts`
- Modify: `web/packages/rpc/src/service.test.ts`
- Modify: `web/apps/server/src/app.test.ts`
**Interfaces:**
- Produces operation names for artifact list/inspect, deployment list/inspect/validate, and run list/inspect/trace.
- Produces interpreted transport objects whose field names are camelCase and whose full raw responses remain in `OperationExchange.exchange`.
- Consumers in later tasks call the existing `WorkflowRpc.execute(operation, target, params)` API.
- [ ] **Step 1: Write failing RPC service tests for all new methods**
Add table-driven cases to `service.test.ts` using the existing fake-fetch pattern:
```ts
const lifecycleCases = [
{
operation: "workflow.artifacts.list",
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.deployments.list",
params: {},
result: {
deployments: [{
id: "report.default",
artifact_id: "report",
artifact_version: 1,
binding_count: 1,
drift_policy: "block",
}],
},
},
{
operation: "workflow.runs.list",
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,
},
},
] as const;
```
For inspect/validate/trace, assert at least the identity, status, plan, bindings,
diagnostics, interrupt, and bounded trace fields used by later view models.
- [ ] **Step 2: Run the focused tests and confirm the red state**
Run:
```powershell
pnpm --dir web --filter @lda/workflow-rpc test -- service.test.ts
```
Expected: FAIL because the operation names are unknown or absent from
`WorkflowRpcs`.
- [ ] **Step 3: Add Effect schemas and RPC declarations**
In `rpcs.ts`, introduce reusable strict primitives and public result schemas:
```ts
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,
});
```
Define one `Rpc.make` per exact public method and add each to `WorkflowRpcs`.
Payloads must mirror `src/wf_transport_rpc_http/models.py`; success schemas
must decode the selected UI fields and preserve plan/output/interrupt/trace
objects as schema-checked records or explicit nullable fields.
- [ ] **Step 4: Extend the operation registry and execution switch**
Add one `OperationMeta` entry per method. Equivalent CLI strings must be:
```text
uv run wf artifact list --limit 50
uv run wf artifact inspect ARTIFACT_ID --version VERSION
uv run wf deploy list
uv run wf deploy inspect DEPLOYMENT_ID
uv run wf deploy validate DEPLOYMENT_ID
uv run wf run list --limit 50
uv run wf run inspect RUN_ID
uv run wf run trace RUN_ID --from START --limit LIMIT
```
Extend `OperationName` and the `executeImpl` switch. Follow the existing
generated-client access style, for example:
```ts
case "workflow.artifacts.list": {
const payload = yield* decodeParams(WorkflowArtifactsListPayloadSchema, params);
return yield* client.workflow["artifacts.list"](payload);
}
```
Keep `metadata.interpret()` inside `decodeOperationMetadata` so schema failures
remain `RpcDecodeError` values with evidence.
- [ ] **Step 5: Run RPC and server tests**
Run:
```powershell
pnpm --dir web --filter @lda/workflow-rpc test
pnpm --dir web --filter @lda/web-server test
```
Expected: all tests pass, including an app test proving a newly registered
operation reaches `RunOperation` rather than returning `unknown_operation`.
- [ ] **Step 6: Commit the RPC read surface**
```powershell
git add web/packages/rpc web/apps/server/src/app.test.ts
git commit -m "feat: expose lifecycle reads to web console"
```
### Task 2: Add Typed Browser Contracts And Lifecycle View Models
**Files:**
- Modify: `web/apps/console/src/connection/contracts.ts`
- Modify: `web/apps/console/src/connection/api.test.ts`
- Create: `web/apps/console/src/lifecycle/models.ts`
- Create: `web/apps/console/src/lifecycle/models.test.ts`
**Interfaces:**
- Produces `ArtifactSummary`, `ArtifactDetail`, `DeploymentSummary`, `DeploymentDetail`, `DeploymentValidation`, `RunSummary`, `RunDetail`, and `TracePage`.
- Produces `decodeArtifactList`, `decodeArtifactDetail`, `decodeDeploymentList`, `decodeDeploymentDetail`, `decodeDeploymentValidation`, `decodeRunList`, `decodeRunDetail`, and `decodeTracePage`.
- Later React tasks consume only these view models.
- [ ] **Step 1: Write failing adapter tests**
Cover valid payloads, missing required identities, negative counts, malformed
plans, nullable interrupt/output fields, and bounded trace metadata. Example:
```ts
it("decodes an artifact list into immutable summaries", () => {
const result = decodeArtifactList({
nodes: [{
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");
});
```
- [ ] **Step 2: Run tests and confirm missing adapters**
```powershell
pnpm --dir web --filter @lda/console test -- models.test.ts
```
Expected: FAIL because `lifecycle/models.ts` does not exist.
- [ ] **Step 3: Extend the operation-name browser contract**
Add all lifecycle method literals to `OperationNameSchema`. Keep the envelope
forward-compatible for error codes, but reject unknown success operation names.
- [ ] **Step 4: Implement Valibot-backed lifecycle adapters**
Each decoder validates `unknown` and returns a plain immutable object. Use a
shared error wrapper:
```ts
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"}`);
};
```
Do not pass transport snake_case values into React. Normalize stable keys,
display labels, status enums, counts, pagination cursors, and inspect payloads
inside these adapters.
- [ ] **Step 5: Run console adapter/API tests**
```powershell
pnpm --dir web --filter @lda/console test -- models.test.ts api.test.ts
```
Expected: all focused tests pass.
- [ ] **Step 6: Commit browser lifecycle models**
```powershell
git add web/apps/console/src/connection web/apps/console/src/lifecycle
git commit -m "feat: add lifecycle console view models"
```
### Task 3: Build Lifecycle Selection State And RPC Orchestration
**Files:**
- Create: `web/apps/console/src/lifecycle/state.ts`
- Create: `web/apps/console/src/lifecycle/state.test.ts`
- Create: `web/apps/console/src/lifecycle/useLifecycleExplorer.ts`
- Create: `web/apps/console/src/lifecycle/useLifecycleExplorer.test.tsx`
**Interfaces:**
- Produces `LifecycleState`, `LifecycleAction`, `lifecycleReducer`, and `initialLifecycleState`.
- Produces `useLifecycleExplorer(target)` with selection commands, refresh commands, load-more commands, and plain state.
- [ ] **Step 1: Write reducer tests for selection invariants**
Pin these transitions:
```ts
selectArtifact("report@1")
// clears selectedDeploymentId, selectedRunId, deployment detail, run detail, trace
selectDeployment("report.default")
// clears selectedRunId, run detail, trace
targetChanged()
// returns the complete lifecycle state to its initial value
```
Also cover loading, empty, partial error, append-page, inspect-success, and
trace-page states.
- [ ] **Step 2: Run reducer tests and confirm red**
```powershell
pnpm --dir web --filter @lda/console test -- lifecycle/state.test.ts
```
Expected: FAIL because the reducer is missing.
- [ ] **Step 3: Implement the pure reducer**
Use separate operation states rather than one global loading flag:
```ts
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 };
```
Keep ids and decoded records separate so selection changes do not mutate list
objects.
- [ ] **Step 4: Write hook tests with deferred promises**
Mock `callOperation` and prove:
- initial target load requests artifact/deployment/run lists with limit 50;
- selecting an artifact requests inspect once;
- a late artifact response is ignored after a newer selection;
- reconnect invalidates all outstanding generations;
- deployment validation failure leaves artifact detail visible;
- trace next-page appends only the matching run's frames.
- [ ] **Step 5: Implement `useLifecycleExplorer`**
Use one generation counter for target-wide list loads and one counter per
selected inspect/trace chain. Dispatch evidence through a supplied callback:
```ts
export const useLifecycleExplorer = (
target: string | null,
recordEvidence: (record: EvidenceRecord) => void,
): LifecycleExplorerController => { /* orchestrate callOperation */ };
```
Never duplicate evidence DTO construction; extract the existing App logic into
a small helper if both source inventory and lifecycle operations need it.
- [ ] **Step 6: Run reducer and hook tests**
```powershell
pnpm --dir web --filter @lda/console test -- lifecycle
```
Expected: all lifecycle state/orchestration tests pass.
- [ ] **Step 7: Commit lifecycle state management**
```powershell
git add web/apps/console/src/lifecycle
git commit -m "feat: orchestrate lifecycle explorer reads"
```
### Task 4: Render The Lifecycle Master-Detail Explorer
**Files:**
- Create: `web/apps/console/src/lifecycle/RecordColumns.tsx`
- Create: `web/apps/console/src/lifecycle/RecordDetails.tsx`
- Create: `web/apps/console/src/lifecycle/LifecycleExplorer.tsx`
- Create: `web/apps/console/src/lifecycle/LifecycleExplorer.test.tsx`
- Modify: `web/apps/console/src/styles/global.css`
**Interfaces:**
- Consumes `LifecycleExplorerController` from Task 3.
- Produces a generic artifact -> deployment -> run navigation shell and focus-mode buttons.
- Emits selected artifact plan and selected run/trace data to graph/execution children introduced later.
- [ ] **Step 1: Write component tests for the complete read spine**
Render a loaded controller fixture and assert:
```ts
expect(screen.getByRole("button", { name: /Report.*version 1/i })).toBeVisible();
expect(screen.getByRole("button", { name: /report.default/i })).toBeVisible();
expect(screen.getByRole("button", { name: /run_1.*interrupted/i })).toBeVisible();
```
Click each level and assert descendant selection callbacks. Add explicit tests
for empty artifacts, unrelated deployments, unavailable trace, and partial
validation errors.
- [ ] **Step 2: Run the component test and confirm red**
```powershell
pnpm --dir web --filter @lda/console test -- LifecycleExplorer.test.tsx
```
Expected: FAIL because the components do not exist.
- [ ] **Step 3: Implement accessible list columns and record details**
Use semantic buttons/lists and visible selection state. Details must prioritize
interpreted values:
- artifact: title, id/version, kind, outcomes, required sources;
- deployment: id, artifact ref, drift policy, bindings, validation status and diagnostics;
- run: id, deployment, artifact ref, status, readiness, outcome, updated time.
Do not render whole-object JSON in these panels.
- [ ] **Step 4: Implement focus navigation**
`LifecycleExplorer` owns four buttons: Lifecycle, Graph, Execution, Raw. Disable
Graph without an inspected artifact and Execution without an inspected run.
Raw remains available when evidence exists.
- [ ] **Step 5: Add responsive styles**
Desktop uses three linked columns plus a details panel. Narrow screens use
horizontal focus navigation and one active column at a time; do not shrink all
columns until unreadable.
- [ ] **Step 6: Run explorer tests and typecheck**
```powershell
pnpm --dir web --filter @lda/console test -- LifecycleExplorer.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Expected: both commands pass.
- [ ] **Step 7: Commit the lifecycle UI**
```powershell
git add web/apps/console/src/lifecycle web/apps/console/src/styles/global.css
git commit -m "feat: render lifecycle master detail explorer"
```
### Task 5: Add Library-Backed Workflow Graph Adaptation
**Files:**
- Modify: `web/apps/console/package.json`
- Modify: `web/pnpm-lock.yaml`
- Create: `web/apps/console/src/graph/graph-model.ts`
- Create: `web/apps/console/src/graph/graph-model.test.ts`
**Interfaces:**
- Produces `WorkflowGraphModel`, `WorkflowGraphNodeData`, and `buildWorkflowGraph(plan)`.
- Uses `@dagrejs/dagre` only for layout; it never writes coordinates back to workflow data.
- [ ] **Step 1: Install graph dependencies**
```powershell
pnpm --dir web --filter @lda/console add @xyflow/react@12.11.1 @dagrejs/dagre@3.0.0
```
Both pinned packages ship TypeScript declarations; do not add a legacy
`@types/dagre` package.
- [ ] **Step 2: Write failing graph-model tests**
Use fixtures containing capability use, condition, interrupt, foreach, join,
and end nodes. Assert stable ids, semantic labels, route edges, node kinds,
deterministic coordinates, and immutability of the input plan.
```ts
const first = buildWorkflowGraph(plan);
const second = buildWorkflowGraph(structuredClone(plan));
expect(second).toEqual(first);
expect(plan).toEqual(originalPlan);
```
- [ ] **Step 3: Run graph-model tests and confirm red**
```powershell
pnpm --dir web --filter @lda/console test -- graph-model.test.ts
```
Expected: FAIL because the adapter is missing.
- [ ] **Step 4: Implement plan normalization and Dagre layout**
Decode only the plan structures required to render nodes/edges. Convert all
supported node variants into a common view model:
```ts
export type WorkflowGraphNodeData = {
readonly nodeId: string;
readonly kind: "use" | "condition" | "interrupt" | "foreach" | "join" | "end" | "control";
readonly label: string;
readonly capability: string | null;
readonly outcomes: ReadonlyArray<string>;
readonly raw: Readonly<Record<string, unknown>>;
};
```
Use Dagre's directed layout with fixed presentation dimensions. Sort nodes and
edges by stable ids before layout so identical plans produce identical models.
- [ ] **Step 5: Run graph tests and typecheck**
```powershell
pnpm --dir web --filter @lda/console test -- graph-model.test.ts
pnpm --dir web --filter @lda/console typecheck
```
Expected: all checks pass.
- [ ] **Step 6: Commit graph dependencies and model**
```powershell
git add web/apps/console/package.json web/pnpm-lock.yaml web/apps/console/src/graph
git commit -m "feat: adapt workflow plans for graph rendering"
```
### Task 6: Render The Workflow Graph And Node Inspector
**Files:**
- Create: `web/apps/console/src/graph/WorkflowGraph.tsx`
- Create: `web/apps/console/src/graph/WorkflowGraph.test.tsx`
- Create: `web/apps/console/src/graph/NodeInspector.tsx`
- Modify: `web/apps/console/src/styles/global.css`
**Interfaces:**
- Consumes `WorkflowGraphModel` and an optional active trace node id.
- Produces node selection events and a semantic inspector drawer.
- [ ] **Step 1: Write failing graph interaction tests**
Mock `@xyflow/react` only where jsdom lacks layout APIs. Assert that nodes and
edges render, selecting `review` opens the inspector, and `activeNodeId` marks
the matching node without changing graph data.
- [ ] **Step 2: Run the component test and confirm red**
```powershell
pnpm --dir web --filter @lda/console test -- WorkflowGraph.test.tsx
```
Expected: FAIL because the graph components are missing.
- [ ] **Step 3: Implement the graph canvas**
Import `@xyflow/react/dist/style.css`, render controls/background/fit-view, and
use custom node presentation for semantic kinds. Keep the graph read-only:
disable connect, delete, drag persistence, and mutation callbacks.
- [ ] **Step 4: Implement the node inspector**
Show node kind, capability/source reference, input/output bindings, outcomes,
and routes. Put raw node JSON behind a collapsed disclosure labeled
**Raw node definition**.
- [ ] **Step 5: Run graph component tests and accessibility checks**
```powershell
pnpm --dir web --filter @lda/console test -- WorkflowGraph.test.tsx
pnpm --dir web --filter @lda/console typecheck
```
Expected: all checks pass with no missing accessible names.
- [ ] **Step 6: Commit graph presentation**
```powershell
git add web/apps/console/src/graph web/apps/console/src/styles/global.css
git commit -m "feat: render interactive workflow graph"
```
### Task 7: Add Run Execution, Trace, And Interrupt Views
**Files:**
- Create: `web/apps/console/src/execution/trace-model.ts`
- Create: `web/apps/console/src/execution/trace-model.test.ts`
- Create: `web/apps/console/src/execution/ExecutionView.tsx`
- Create: `web/apps/console/src/execution/ExecutionView.test.tsx`
- Modify: `web/apps/console/src/styles/global.css`
**Interfaces:**
- Produces `TraceFrameView`, `buildTraceFrames(tracePage)`, and `ExecutionView`.
- Emits selected frame node ids to `WorkflowGraph` for correlation.
- [ ] **Step 1: Write failing trace adapter tests**
Use the public trace shape:
```ts
{
frame_id: "root",
node_id: "review",
step_type: "interrupt",
resolved_input: { report: "..." },
outcome: "submitted",
next_node_id: "create_issues",
output: {},
state_changes: {},
}
```
Assert stable frame keys, node correlation, concise summaries, ordering, and
next-page state from `trace_start`, `trace_limit`, and `trace_truncated`.
- [ ] **Step 2: Run trace tests and confirm red**
```powershell
pnpm --dir web --filter @lda/console test -- trace-model.test.ts
```
Expected: FAIL because trace adapters are missing.
- [ ] **Step 3: Implement trace adaptation**
Never stringify unbounded input/output into list rows. Produce counts and short
field summaries; retain full decoded values only for the selected frame drawer.
- [ ] **Step 4: Write and implement execution component tests**
Cover completed, failed, and interrupted runs. The interrupted fixture must
render `kind`, payload, outcomes, request schema, resume schema, and typed flag,
but no submit/resume button.
Selecting a trace frame must call:
```ts
onSelectNode(frame.nodeId);
```
- [ ] **Step 5: Run execution tests and typecheck**
```powershell
pnpm --dir web --filter @lda/console test -- execution
pnpm --dir web --filter @lda/console typecheck
```
Expected: all checks pass.
- [ ] **Step 6: Commit execution and trace views**
```powershell
git add web/apps/console/src/execution web/apps/console/src/styles/global.css
git commit -m "feat: render run execution and trace"
```
### Task 8: Integrate The Explorer, Document Smoke, And Verify
**Files:**
- Modify: `web/apps/console/src/app/App.tsx`
- Modify: `web/apps/console/src/app/App.test.tsx`
- Modify: `web/apps/console/src/app/state.ts`
- Modify: `web/apps/console/src/components/ProtocolEvidence.tsx`
- Modify: `web/apps/console/src/styles/global.css`
- Modify: `web/README.md`
- Modify: `docs/current_roadmap.md`
- Move after completion: `docs/superpowers/plans/2026-07-02-workflow-console-lifecycle-explorer.md` -> `docs/historical/superpowers/plans/2026-07-02-workflow-console-lifecycle-explorer.md`
**Interfaces:**
- Connects the existing connection/source foundation to Lifecycle, Graph, Execution, and Raw focus modes.
- Preserves the current source inventory and protocol evidence behavior.
- [ ] **Step 1: Write the failing App integration test**
Mock lifecycle RPC responses and prove this sequence:
```text
Connect -> artifacts load -> select artifact -> Graph enabled
-> select deployment -> select run -> Execution enabled
-> select trace frame -> matching graph node active
-> Raw shows evidence for each call
```
Also prove reconnect clears lifecycle selection and ignores late responses from
the previous target.
- [ ] **Step 2: Run the App test and confirm red**
```powershell
pnpm --dir web --filter @lda/console test -- App.test.tsx
```
Expected: FAIL because App does not mount the explorer.
- [ ] **Step 3: Integrate focus modes without growing App orchestration**
`App.tsx` should instantiate `useLifecycleExplorer` and pass the controller to
`LifecycleExplorer`; it must not absorb lifecycle reducer cases or transport
decoding. Reuse `ProtocolEvidence` for Raw focus rather than adding a second raw
viewer.
- [ ] **Step 4: Document the optional live smoke**
Add these exact prerequisites and checks to `web/README.md`:
```powershell
uv run wf-rpc-server --config examples/lda_report_workflow/wf.config.json --host 127.0.0.1 --port 8765
pnpm --dir web dev
```
Document how to seed or generate the example's artifact/deployment/run using
its existing README or build script; do not introduce a helper that drives
`WorkflowApi` directly. The smoke passes when artifact, deployment, run, graph,
trace, and raw evidence are visible.
- [ ] **Step 5: Run complete verification**
```powershell
pnpm --dir web test
pnpm --dir web typecheck
pnpm --dir web build
uv run pytest tests/docs -q -n0
git diff --check
```
Expected: all commands exit 0. CRLF conversion warnings are acceptable;
whitespace errors are not.
- [ ] **Step 6: Perform the optional live smoke**
With the Python server and web dev process running, verify:
```powershell
Invoke-RestMethod http://127.0.0.1:8787/api/health
```
Then inspect the lifecycle through `http://127.0.0.1:5173`. Record any skipped
fixture setup explicitly; do not claim live smoke success from unit tests.
- [ ] **Step 7: Update roadmap and archive the plan**
Mark roadmap item 4 completed only after the full verification and live smoke
requirements have been met. Move this plan to the historical mirror path and
update any live links.
- [ ] **Step 8: Commit integration and documentation**
```powershell
git add web docs/current_roadmap.md docs/historical/superpowers/plans/2026-07-02-workflow-console-lifecycle-explorer.md
git commit -m "feat: add workflow lifecycle explorer"
```
## Plan Self-Review
- Spec coverage: public RPC reads, pagination, stale guards, linking, graph,
trace, interrupt contract, evidence, error states, testing, and live smoke
each map to explicit tasks.
- Scope: draft workspace inspection remains a named follow-up and is not mixed
into the artifact-to-run vertical slice.
- Type consistency: lifecycle adapters feed the controller; the controller
feeds React; graph and trace models share stable `nodeId` strings.
- Placeholder scan: no TBD/TODO or unspecified implementation steps remain.
+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