feat: add workflow console lifecycle explorer
This commit is contained in:
@@ -49,10 +49,12 @@ Implementation order:
|
|||||||
[`workflow console foundation`](superpowers/specs/2026-07-01-workflow-console-foundation-design.md).
|
[`workflow console foundation`](superpowers/specs/2026-07-01-workflow-console-foundation-design.md).
|
||||||
Implementation:
|
Implementation:
|
||||||
[`workflow console foundation plan`](historical/superpowers/plans/2026-07-02-workflow-console-foundation.md).
|
[`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
|
4. Completed: add the generic console lifecycle explorer, exercised first through
|
||||||
artifact -> deployment -> run -> trace path, with interactive graph and raw
|
the artifact -> deployment -> run -> trace path, with interactive graph and raw
|
||||||
RPC evidence. Design:
|
RPC evidence. Design:
|
||||||
[`workflow console lifecycle explorer`](superpowers/specs/2026-07-02-workflow-console-lifecycle-explorer.md).
|
[`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
|
Draft workspace inspection reuses the same shell after the first vertical
|
||||||
path.
|
path.
|
||||||
5. Add lifecycle autoplay, typed approval, issue-board output, and replay.
|
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.
|
||||||
@@ -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
|
validates targets against loopback policy, executes typed JSON-RPC calls
|
||||||
through Effect, and returns plain JSON DTOs to the browser.
|
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
|
## Security
|
||||||
|
|
||||||
- Only loopback targets are accepted (`127.0.0.1`, `localhost`, `[::1]`)
|
- 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
|
6. `http://example.com:8765/rpc` is rejected without upstream fetch
|
||||||
7. Stopping the Python server produces the unreachable state while preserving
|
7. Stopping the Python server produces the unreachable state while preserving
|
||||||
the entered URL
|
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.
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
"preview": "vite preview --host 127.0.0.1"
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "3.0.0",
|
||||||
"@fontsource-variable/source-sans-3": "5.2.9",
|
"@fontsource-variable/source-sans-3": "5.2.9",
|
||||||
"@fontsource/barlow-condensed": "5.2.8",
|
"@fontsource/barlow-condensed": "5.2.8",
|
||||||
"@fontsource/ibm-plex-mono": "5.2.7",
|
"@fontsource/ibm-plex-mono": "5.2.7",
|
||||||
|
"@xyflow/react": "12.11.1",
|
||||||
"react": "19.2.7",
|
"react": "19.2.7",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"valibot": "1.4.2"
|
"valibot": "1.4.2"
|
||||||
|
|||||||
@@ -92,17 +92,28 @@ describe("App", () => {
|
|||||||
it("ignores stale source inventory responses after reconnect", async () => {
|
it("ignores stale source inventory responses after reconnect", async () => {
|
||||||
const firstSources = deferred<RpcResponse>();
|
const firstSources = deferred<RpcResponse>();
|
||||||
const secondSources = 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
|
mockedConnectToServer
|
||||||
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
|
.mockResolvedValueOnce(successfulConnection("http://first.example/rpc"))
|
||||||
.mockResolvedValueOnce(successfulConnection("http://second.example/rpc"));
|
.mockResolvedValueOnce(successfulConnection("http://second.example/rpc"));
|
||||||
mockedCallOperation
|
mockedCallOperation.mockImplementation((op: string) => {
|
||||||
.mockReturnValueOnce(firstSources.promise)
|
if (op === "workflow.sources.list") return latestSourcesDeferred.promise;
|
||||||
.mockReturnValueOnce(secondSources.promise);
|
return Promise.resolve(lifecycleOk);
|
||||||
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
await screen.findByTestId("sources-loading");
|
|
||||||
|
|
||||||
|
latestSourcesDeferred = secondSources;
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Reconnect" }));
|
await userEvent.click(screen.getByRole("button", { name: "Reconnect" }));
|
||||||
secondSources.resolve(successfulSources("local.second"));
|
secondSources.resolve(successfulSources("local.second"));
|
||||||
firstSources.resolve(successfulSources("local.first"));
|
firstSources.resolve(successfulSources("local.first"));
|
||||||
@@ -114,4 +125,26 @@ describe("App", () => {
|
|||||||
expect(screen.queryByTestId("source-id-local.first")).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { useReducer, useEffect, useCallback, useRef } from "react";
|
|||||||
import {
|
import {
|
||||||
connectionReducer,
|
connectionReducer,
|
||||||
initialState,
|
initialState,
|
||||||
|
type EvidenceRecord,
|
||||||
type SourceRecord,
|
type SourceRecord,
|
||||||
} from "./state.js";
|
} from "./state.js";
|
||||||
import { connectToServer, callOperation } from "../connection/api.js";
|
import { connectToServer, callOperation } from "../connection/api.js";
|
||||||
import { ConnectionHeader } from "../components/ConnectionHeader.js";
|
import { ConnectionHeader } from "../components/ConnectionHeader.js";
|
||||||
import { SourceInventory } from "../components/SourceInventory.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 = (
|
const parseSources = (
|
||||||
data: unknown,
|
data: unknown,
|
||||||
@@ -44,6 +46,15 @@ export const App = () => {
|
|||||||
const connectGeneration = useRef(0);
|
const connectGeneration = useRef(0);
|
||||||
const sourcesGeneration = 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(
|
const loadSources = useCallback(
|
||||||
async (target: string) => {
|
async (target: string) => {
|
||||||
const generation = ++sourcesGeneration.current;
|
const generation = ++sourcesGeneration.current;
|
||||||
@@ -154,7 +165,9 @@ export const App = () => {
|
|||||||
loading={state.sourcesLoading}
|
loading={state.sourcesLoading}
|
||||||
error={state.sourceError}
|
error={state.sourceError}
|
||||||
/>
|
/>
|
||||||
<ProtocolEvidence evidence={state.evidence} />
|
<section aria-label="Lifecycle Explorer" data-testid="lifecycle-explorer">
|
||||||
|
<LifecycleExplorer controller={lifecycleController} />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ const ConnectionSuccessSchema = v.object({
|
|||||||
const OperationNameSchema = v.union([
|
const OperationNameSchema = v.union([
|
||||||
v.literal("workflow.health"),
|
v.literal("workflow.health"),
|
||||||
v.literal("workflow.sources.list"),
|
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({
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -11,7 +11,21 @@ export {
|
|||||||
|
|
||||||
export { normalizeLoopbackTarget } from "./target-policy.js";
|
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 { WorkflowRpc, makeWorkflowRpcLayer } from "./service.js";
|
||||||
export type { OperationExchange, WorkflowRpcError, OperationName } from "./service.js";
|
export type { OperationExchange, WorkflowRpcError, OperationName } from "./service.js";
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ import {
|
|||||||
WorkflowHealthResultSchema,
|
WorkflowHealthResultSchema,
|
||||||
WorkflowSourcesListPayloadSchema,
|
WorkflowSourcesListPayloadSchema,
|
||||||
WorkflowSourcesListResultSchema,
|
WorkflowSourcesListResultSchema,
|
||||||
|
WorkflowArtifactsListPayloadSchema,
|
||||||
|
WorkflowArtifactsListResultSchema,
|
||||||
|
WorkflowArtifactsInspectPayloadSchema,
|
||||||
|
WorkflowArtifactsInspectResultSchema,
|
||||||
|
WorkflowDeploymentsListResultSchema,
|
||||||
|
WorkflowDeploymentsInspectPayloadSchema,
|
||||||
|
WorkflowDeploymentsInspectResultSchema,
|
||||||
|
WorkflowDeploymentsValidatePayloadSchema,
|
||||||
|
WorkflowDeploymentsValidateResultSchema,
|
||||||
|
WorkflowRunsListPayloadSchema,
|
||||||
|
WorkflowRunsListResultSchema,
|
||||||
|
WorkflowRunsInspectPayloadSchema,
|
||||||
|
WorkflowRunsInspectResultSchema,
|
||||||
|
WorkflowRunsTracePayloadSchema,
|
||||||
|
WorkflowRunsTraceResultSchema,
|
||||||
} from "./rpcs.js";
|
} from "./rpcs.js";
|
||||||
|
|
||||||
export type OperationMeta = {
|
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(
|
const registry: ReadonlyMap<string, OperationMeta> = new Map(
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ const NonNegativeIntegerSchema = Schema.Number.pipe(
|
|||||||
Schema.between(0, Number.MAX_SAFE_INTEGER),
|
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({
|
export const SourceSummarySchema = Schema.Struct({
|
||||||
id: Schema.String,
|
id: Schema.String,
|
||||||
kind: Schema.String,
|
kind: Schema.String,
|
||||||
@@ -48,4 +68,251 @@ export const WorkflowSourcesList = Rpc.make("workflow.sources.list", {
|
|||||||
error: Schema.Never,
|
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,
|
||||||
|
);
|
||||||
|
|||||||
@@ -73,6 +73,167 @@ const runEither = (
|
|||||||
.pipe(Effect.either);
|
.pipe(Effect.either);
|
||||||
}).pipe(Effect.provide(makeWorkflowRpcLayer(options)), Effect.runPromise);
|
}).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", () => {
|
describe("WorkflowRpc", () => {
|
||||||
it("uses @effect/rpc and returns exact raw request and response evidence", async () => {
|
it("uses @effect/rpc and returns exact raw request and response evidence", async () => {
|
||||||
const fetch: typeof globalThis.fetch = async (input, init) => {
|
const fetch: typeof globalThis.fetch = async (input, init) => {
|
||||||
@@ -251,3 +412,28 @@ describe("WorkflowRpc", () => {
|
|||||||
expect(result.left).toBeInstanceOf(RpcProtocolError);
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,13 +27,31 @@ import {
|
|||||||
WorkflowHealthPayloadSchema,
|
WorkflowHealthPayloadSchema,
|
||||||
WorkflowRpcs,
|
WorkflowRpcs,
|
||||||
WorkflowSourcesListPayloadSchema,
|
WorkflowSourcesListPayloadSchema,
|
||||||
|
WorkflowArtifactsListPayloadSchema,
|
||||||
|
WorkflowArtifactsInspectPayloadSchema,
|
||||||
|
WorkflowDeploymentsListPayloadSchema,
|
||||||
|
WorkflowDeploymentsInspectPayloadSchema,
|
||||||
|
WorkflowDeploymentsValidatePayloadSchema,
|
||||||
|
WorkflowRunsListPayloadSchema,
|
||||||
|
WorkflowRunsInspectPayloadSchema,
|
||||||
|
WorkflowRunsTracePayloadSchema,
|
||||||
} from "./rpcs.js";
|
} from "./rpcs.js";
|
||||||
import { normalizeLoopbackTarget } from "./target-policy.js";
|
import { normalizeLoopbackTarget } from "./target-policy.js";
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MILLISECONDS = 5_000;
|
const DEFAULT_TIMEOUT_MILLISECONDS = 5_000;
|
||||||
const DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
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 {
|
export interface WorkflowRpcOptions {
|
||||||
readonly fetch?: typeof globalThis.fetch;
|
readonly fetch?: typeof globalThis.fetch;
|
||||||
@@ -62,7 +80,16 @@ export type WorkflowRpcError =
|
|||||||
| RpcDecodeError;
|
| RpcDecodeError;
|
||||||
|
|
||||||
const isOperationName = (value: string): value is OperationName =>
|
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 => ({
|
const toExchange = (evidence: EvidenceRecord | null): RpcExchangeEvidence => ({
|
||||||
request: evidence?.request.body ?? null,
|
request: evidence?.request.body ?? null,
|
||||||
@@ -262,6 +289,62 @@ const executeImpl =
|
|||||||
);
|
);
|
||||||
return yield* client.workflow["sources.list"](payload);
|
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(
|
}).pipe(
|
||||||
Effect.provide(protocolLayer),
|
Effect.provide(protocolLayer),
|
||||||
|
|||||||
Generated
+208
@@ -17,6 +17,9 @@ importers:
|
|||||||
|
|
||||||
apps/console:
|
apps/console:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@dagrejs/dagre':
|
||||||
|
specifier: 3.0.0
|
||||||
|
version: 3.0.0
|
||||||
'@fontsource-variable/source-sans-3':
|
'@fontsource-variable/source-sans-3':
|
||||||
specifier: 5.2.9
|
specifier: 5.2.9
|
||||||
version: 5.2.9
|
version: 5.2.9
|
||||||
@@ -26,6 +29,9 @@ importers:
|
|||||||
'@fontsource/ibm-plex-mono':
|
'@fontsource/ibm-plex-mono':
|
||||||
specifier: 5.2.7
|
specifier: 5.2.7
|
||||||
version: 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:
|
react:
|
||||||
specifier: 19.2.7
|
specifier: 19.2.7
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
@@ -180,6 +186,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||||
engines: {node: '>=20.19.0'}
|
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]':
|
'@effect/[email protected]':
|
||||||
resolution: {integrity: sha512-oJm3UztdzZvK7BXkFSV3IdyGuqQrhLmViG/hMDZh99ski7aADesNuv19z4R0heH3bAXnSt/Nb8O9KJQ886C0Tg==}
|
resolution: {integrity: sha512-oJm3UztdzZvK7BXkFSV3IdyGuqQrhLmViG/hMDZh99ski7aADesNuv19z4R0heH3bAXnSt/Nb8O9KJQ886C0Tg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -561,6 +573,24 @@ packages:
|
|||||||
'@types/[email protected]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
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]':
|
'@types/[email protected]':
|
||||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||||
|
|
||||||
@@ -620,6 +650,22 @@ packages:
|
|||||||
'@vitest/[email protected]':
|
'@vitest/[email protected]':
|
||||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -658,6 +704,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -680,6 +729,44 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -1054,6 +1141,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
|
||||||
engines: {node: '>=20.18.1'}
|
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]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1190,6 +1282,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
|
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:
|
snapshots:
|
||||||
|
|
||||||
'@adobe/[email protected]': {}
|
'@adobe/[email protected]': {}
|
||||||
@@ -1252,6 +1359,12 @@ snapshots:
|
|||||||
|
|
||||||
'@csstools/[email protected]': {}
|
'@csstools/[email protected]': {}
|
||||||
|
|
||||||
|
'@dagrejs/[email protected]':
|
||||||
|
dependencies:
|
||||||
|
'@dagrejs/graphlib': 4.0.1
|
||||||
|
|
||||||
|
'@dagrejs/[email protected]': {}
|
||||||
|
|
||||||
'@effect/[email protected]([email protected])':
|
'@effect/[email protected]([email protected])':
|
||||||
dependencies:
|
dependencies:
|
||||||
effect: 3.21.4
|
effect: 3.21.4
|
||||||
@@ -1499,6 +1612,27 @@ snapshots:
|
|||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
assertion-error: 2.0.1
|
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]': {}
|
||||||
|
|
||||||
'@types/[email protected]': {}
|
'@types/[email protected]': {}
|
||||||
@@ -1561,6 +1695,31 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.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]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -1585,6 +1744,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 7.2.0
|
string-width: 7.2.0
|
||||||
@@ -1611,6 +1772,42 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[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]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-mimetype: 5.0.0
|
whatwg-mimetype: 5.0.0
|
||||||
@@ -1963,6 +2160,10 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]([email protected]):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
[email protected]([email protected]):
|
[email protected]([email protected]):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
@@ -2051,3 +2252,10 @@ snapshots:
|
|||||||
string-width: 7.2.0
|
string-width: 7.2.0
|
||||||
y18n: 5.0.8
|
y18n: 5.0.8
|
||||||
yargs-parser: 22.0.0
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user