Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoju 43978360ff feat(workflow-util): add frontmatter markdown parser and validator
Phase 1 of RFC #351 — define AgentFrontmatter type, parseFrontmatterMarkdown()
and validateFrontmatter() with 45 tests.

- Built-in minimal YAML parser (no new deps)
- Never throws on malformed input — degrades gracefully
- All fields use T | null (no optional properties)

Refs #351
2026-05-19 04:41:56 +00:00
105 changed files with 1224 additions and 6700 deletions
-35
View File
@@ -1,35 +0,0 @@
# Uncaged Workflow Architecture
Uncaged Workflow is a monorepo implementing a workflow engine that executes single-file ESM bundles. Each workflow is identified by an XXH64 hash (Crockford Base32); execution state is stored in a content-addressable store (CAS) as immutable Merkle nodes. Agents are pluggable — the same workflow definition runs with Cursor, Hermes, a raw LLM, or a ReAct loop.
## Core Concepts
| Card | Description |
|------|-------------|
| [Bundle](./bundle.md) | A single-file `.esm.js` module with an XXH64 hash identity, stored in `~/.uncaged/workflow/bundles/` |
| [Thread](./thread.md) | A single execution instance of a workflow, identified by a ULID, with CAS-linked state nodes |
| [CAS](./cas.md) | The content-addressable store that holds all immutable blobs — content, start nodes, and state nodes |
| [Registry](./registry.md) | `workflow.yaml` — maps workflow names to current and historical bundle hashes |
## Execution
| Card | Description |
|------|-------------|
| [Engine](./engine.md) | The three-phase loop that drives the workflow `AsyncGenerator` and writes each step to CAS |
| [Role](./role.md) | A named actor defined as pure data (`RoleDefinition`) — description, system prompt, and Zod schema |
| [Agent Binding](./agent-binding.md) | The runtime binding that connects a role to a concrete agent implementation via `AdapterFn` |
| [Reactor](./reactor.md) | The ReAct loop abstraction for LLM function-calling, used by both the extract phase and agent adapters |
## Tooling
| Card | Description |
|------|-------------|
| [CLI](./cli.md) | The `uncaged-workflow` command-line tool for managing workflows, threads, and CAS |
| [Dashboard](./dashboard.md) | A private React app for inspecting threads, workflows, and live execution via the gateway |
| [Package Map](./package-map.md) | All packages in the monorepo with their layer positions and dependency graph |
## Authoring
| Card | Description |
|------|-------------|
| [Workflow Templates](./workflow-templates.md) | The `solve-issue` and `develop` reference templates and how to author custom workflows |
-104
View File
@@ -1,104 +0,0 @@
# Agent Binding
> The runtime connection between a workflow's role definitions and a concrete agent implementation, expressed as an `AdapterBinding` passed to `createWorkflow`.
## Overview
Agent binding is how a workflow author specifies which agent executes each role. Roles are pure data (see [Role](./role.md)); the binding supplies the execution strategy. The same `WorkflowDefinition` can be run with different agents by changing the `AdapterBinding` — useful for testing, cost optimization, or environment-specific deployment.
An `AdapterFn` receives a role's `systemPrompt` and Zod `schema`, and returns a `RoleFn` — a function that takes `ThreadContext` and `WorkflowRuntime` and returns `RoleResult<T>`. The adapter is responsible for producing typed structured output directly; there is no separate extract phase when using adapters.
## Key Types
```typescript
// The core adapter interface
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
type RoleResult<T> = { meta: T; childThread: string | null };
// The binding passed to createWorkflow
type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
```
`overrides` allows per-role adapters — for example, using Cursor for one role and an LLM for another within the same workflow.
## AgentFn (Legacy / Low-level)
Below the adapter layer, the original `AgentFn` type still exists for agent implementations that produce raw strings rather than structured output:
```typescript
type AgentFn<Opt = void> = Opt extends void
? (ctx: ThreadContext) => Promise<string>
: (ctx: ThreadContext, options: Opt) => Promise<string>;
```
The `createAgentAdapter` utility in `@uncaged/workflow-util-agent` wraps an `AgentFn` into an `AdapterFn` by composing it with extraction logic.
## Concrete Implementations
| Package | Export | Agent |
|---------|--------|-------|
| `@uncaged/workflow-agent-cursor` | `createCursorAgent` | Runs `cursor` CLI non-interactively in a workspace directory |
| `@uncaged/workflow-agent-hermes` | `createHermesAgent` | Runs `hermes chat` with `--yolo --quiet` (Nerve-style argv) |
| `@uncaged/workflow-agent-llm` | `createLlmAdapter` | Direct LLM completion via the OpenAI-compatible chat endpoint |
| `@uncaged/workflow-agent-react` | `createReactAdapter` | ReAct loop with file and shell tools (read, write, patch, exec) |
All four return an `AdapterFn` suitable for use in `AdapterBinding.adapter`.
## workflow-util-agent
`@uncaged/workflow-util-agent` provides two helpers shared by adapter implementations:
- **`buildThreadInput(ctx)`** — constructs the user-message string from thread context (task, previous steps, tool hints). Used by all CLI-based agents.
- **`spawnCli(command, args, opts)`** — spawns an external process (e.g., `cursor`, `hermes`) and captures stdout, with optional timeout.
- **`createAgentAdapter(agentFn, optionsFn)`** — wraps an `AgentFn<Opt>` into an `AdapterFn`, handling the options extraction step.
## Cursor Agent
`createCursorAgent(config)` invokes the `cursor` CLI binary:
```
cursor -p <fullPrompt> --model <model> --workspace <path> --output-format text --trust --force
```
The workspace path is taken from `config.workspace` or extracted from the thread context via `runtime.extract`.
## Hermes Agent
`createHermesAgent(config)` invokes `hermes chat`:
```
hermes chat -q <fullPrompt> --yolo --max-turns 90 --quiet [--model <model>]
```
## LLM Adapter
`createLlmAdapter(provider)` calls the OpenAI-compatible chat completions endpoint directly. It builds a two-message conversation (system + user) from the role's `systemPrompt` and `buildThreadInput` output, then extracts structured output from the response.
## React Adapter
`createReactAdapter(config)` creates a ReAct loop agent with four default tools: `read_file`, `write_file`, `patch_file`, and `shell_exec`. The loop continues until the agent calls the structured extraction tool or until `maxRounds` is exceeded.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `AdapterFn`, `AdapterBinding`, `RoleFn`, `RoleResult`, `AgentFn` |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — dispatches `adapterForRole` each iteration |
| `@uncaged/workflow-util-agent` | `src/build-agent-prompt.ts` | `buildThreadInput`, `buildAgentPrompt` |
| `@uncaged/workflow-util-agent` | `src/spawn-cli.ts` | `spawnCli` — subprocess runner with timeout |
| `@uncaged/workflow-util-agent` | `src/create-agent-adapter.ts` | `createAgentAdapter` — wraps `AgentFn` into `AdapterFn` |
| `@uncaged/workflow-agent-cursor` | `src/index.ts` | `createCursorAgent` |
| `@uncaged/workflow-agent-hermes` | `src/index.ts` | `createHermesAgent` |
| `@uncaged/workflow-agent-llm` | `src/create-llm-adapter.ts` | `createLlmAdapter` |
| `@uncaged/workflow-agent-react` | `src/create-react-adapter.ts` | `createReactAdapter` |
## See Also
- [Role](./role.md) — the pure data that the binding executes
- [Engine](./engine.md) — the loop that invokes the bound adapter each step
-83
View File
@@ -1,83 +0,0 @@
# Bundle
> A self-contained single-file ESM module (`.esm.js`) that implements one workflow, identified by its XXH64 hash encoded as 13-char Crockford Base32.
## Overview
A bundle is the physical unit of workflow distribution. Workflow authors build their TypeScript source into a single ESM file using `bun build` with `@uncaged/*` packages as externals. The resulting `.esm.js` is the artifact that gets registered and executed.
Every bundle is immutable and content-addressed: its identity is the XXH64 hash of its bytes, encoded as 13 characters of Crockford Base32 (e.g., `3TNKQRJ7BM4XH`). Registering a bundle with a new version simply adds a new hash entry; old hashes stay in the registry history and remain valid.
Bundles are stored on disk at `~/.uncaged/workflow/bundles/<hash>/` after registration. The `cas/` and `threads.json` for that bundle's execution state live under the same directory.
## Exports
Every valid bundle must export exactly two named exports — no default export is permitted:
| Export | Type | Description |
|--------|------|-------------|
| `run` | `WorkflowFn` | The `AsyncGenerator` that drives the execution loop |
| `descriptor` | `WorkflowDescriptor` | Serializable metadata: description, roles, and routing graph |
```typescript
// Minimal bundle shape
export const run: WorkflowFn = createWorkflow(def, binding);
export const descriptor: WorkflowDescriptor = buildDescriptor(def);
```
The validator in `@uncaged/workflow-register` enforces this contract before a bundle can be registered — see `extractBundleExports`.
## Hash Algorithm
The bundle hash is computed with **XXH64** (seed 0) over the raw bytes of the `.esm.js` file, then encoded as 13-char Crockford Base32 using `encodeUint64AsCrockford`:
```typescript
// packages/workflow-cas/src/hash.ts
export function hashWorkflowBundleBytes(data: Uint8Array): string {
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
const digest = XXH.h64(0).update(buf).digest();
return encodeUint64AsCrockford(digestToUint64(digest));
}
```
The same algorithm hashes CAS blob content (`hashString`), so all IDs in the system are consistent Crockford Base32 strings.
## Build Process
Bundles are not distributed from the monorepo directly. The typical flow is:
1. Create a separate workspace (e.g., `my-workflows/`) with `@uncaged/workflow-runtime` as a dependency.
2. Write a TypeScript workflow module that imports `createWorkflow` from `@uncaged/workflow-runtime`.
3. Run `bun build --entrypoints src/my-workflow.ts --outfile dist/my-workflow.esm.js --format esm --external '@uncaged/*'`.
4. Register with `uncaged-workflow workflow add <name> dist/my-workflow.esm.js`.
## Storage Layout
```
~/.uncaged/workflow/
workflow.yaml # registry (name → hash mapping)
bundles/
<hash>/
threads.json # active thread index
history/
YYYY-MM-DD.jsonl # completed thread records
cas/
<hash>.txt # CAS blobs (all bundles share one global CAS)
```
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashWorkflowBundleBytes` and `hashString` — XXH64 + Crockford encoding |
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Loads a `.esm.js` bundle and validates `run` + `descriptor` |
| `@uncaged/workflow-register` | `src/bundle/bundle-validator.ts` | Schema validation of bundle exports |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the primary bundle authoring function |
| `@uncaged/workflow-util` | `src/base32.ts` | `encodeUint64AsCrockford` — Crockford Base32 encoding |
| `@uncaged/workflow-util` | `src/storage-root.ts` | `getDefaultWorkflowStorageRoot``~/.uncaged/workflow` |
## See Also
- [Registry](./registry.md) — how bundles are registered and named in `workflow.yaml`
- [Thread](./thread.md) — how a bundle's `run` export is executed as a thread
- [Engine](./engine.md) — the executor that drives the bundle's `AsyncGenerator`
-111
View File
@@ -1,111 +0,0 @@
# CAS (Content-Addressable Storage)
> An append-only store where every blob is identified by its XXH64 hash, used to persist all workflow thread state as immutable Merkle nodes.
## Overview
CAS is the persistence substrate for the entire workflow engine. Rather than mutating a database row, every piece of state — agent output, role metadata, thread start parameters — is serialized as a YAML blob and stored under its hash. Because content determines identity, the same content always maps to the same hash, and writes are idempotent.
The `CasStore` interface is intentionally simple: `put`, `get`, `delete`, `list`. The default filesystem implementation stores each blob as `<hash>.txt` under `~/.uncaged/workflow/cas/`. Writes use an atomic rename-from-tmp pattern to prevent partial writes.
## Hash Algorithm
All hashes in the system are **XXH64** (seed 0) over UTF-8 content, encoded as 13-char Crockford Base32. This applies to both CAS blob hashes and bundle file hashes. The encoding function `encodeUint64AsCrockford` lives in `@uncaged/workflow-util`.
## Node Types
The CAS holds three types of YAML nodes, all sharing the `{ type, payload, refs }` envelope:
### `content` node
Stores the raw text output of an agent or the initial prompt. `refs` lists any artifact hashes the content references.
```yaml
type: content
payload: "The implementation is complete. Changed files: src/foo.ts"
refs:
- 3TNKQRJ7BM4XH # optional artifact refs
```
### `start` node
Written once when a thread begins. Anchors the thread to a specific workflow name, bundle hash, and depth level.
```yaml
type: start
payload:
name: solve-issue
hash: 3TNKQRJ7BM4XH
depth: 0
parentState: null
refs:
- <promptHash>
```
### `state` node
Written once per completed role step. Points back to the `start` node, the role's content node, and maintains an ancestor skip-list for traversal.
```yaml
type: state
payload:
role: coder
meta: { status: "done", completedPhase: "..." }
start: <startHash>
content: <contentHash>
ancestors: [<prev_state>, ...]
compact: null
timestamp: 1716000000000
childThread: null
refs:
- <contentHash>
- <startHash>
- <ancestor hashes>
```
## Merkle Structure
The `ancestors` array in each `StateNode` implements a **skip-list** capped at 11 entries (1 direct parent + up to 10 skip-list ancestors). This allows `O(log n)` traversal of the chain without loading every node, while keeping each blob self-contained.
```mermaid
graph LR
S[StartNode] --> C1[content₁]
N1[StateNode₁] --> S
N1 --> C1
N2[StateNode₂] --> N1
N2 --> S
N2 --> C2[content₂]
END[StateNode __end__] --> N2
END --> S
```
## CasStore Interface
```typescript
type CasStore = {
put(content: string): Promise<string>; // returns hash
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
```
`put` normalizes raw strings into `content` Merkle nodes before hashing; pre-serialized RFC v3 nodes pass through unchanged.
## Garbage Collection
`cas gc` performs a mark-and-sweep over all CAS blobs. It seeds the reachable set from `head` and `start` hashes in every `threads.json` and `history/*.jsonl`, then traverses `refs` edges transitively. Unreachable blobs are deleted. The result reports `scannedThreads`, `activeRefs`, and `deletedEntries`.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `CasStore` interface definition |
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StateNode`, `ContentMerkleNode` types |
| `@uncaged/workflow-cas` | `src/cas.ts` | `createCasStore` — filesystem implementation |
| `@uncaged/workflow-cas` | `src/hash.ts` | `hashString`, `hashWorkflowBundleBytes` — XXH64 + Crockford |
| `@uncaged/workflow-cas` | `src/nodes.ts` | `putStartNode`, `putStateNode`, `putContentNodeWithRefs`, `parseCasThreadNode` |
| `@uncaged/workflow-cas` | `src/merkle.ts` | `parseMerkleNode`, `serializeMerkleNode`, `getContentMerklePayload` |
| `@uncaged/workflow-cas` | `src/reachable.ts` | Reachability traversal for GC |
| `@uncaged/workflow-execute` | `src/engine/gc.ts` | GC orchestration |
## See Also
- [Thread](./thread.md) — how thread execution state maps to CAS nodes
-107
View File
@@ -1,107 +0,0 @@
# CLI
> `uncaged-workflow` — the command-line tool for registering bundles, running threads, inspecting CAS, and connecting to the gateway.
## Overview
The CLI (`@uncaged/cli-workflow`) is the primary human interface to the workflow engine. It is a multi-level command dispatcher: top-level command groups (`workflow`, `thread`, `cas`, `init`, `setup`) each have a set of subcommands. Two shortcuts (`run`, `live`) alias frequently-used subcommands.
The storage root defaults to `~/.uncaged/workflow` and can be overridden with `WORKFLOW_STORAGE_ROOT` or `UNCAGED_WORKFLOW_STORAGE_ROOT` environment variables.
## Command Reference
### Workflow Registry (`workflow`)
| Subcommand | Args | Description |
|-----------|------|-------------|
| `workflow add` | `<name> <file.esm.js> [--types <path>]` | Register a workflow bundle in the registry |
| `workflow list` | | List all registered workflows |
| `workflow show` | `<name>` | Show bundle hash, timestamp, and descriptor |
| `workflow rm` | `<name>` | Remove a workflow from the registry |
| `workflow history` | `<name>` | Show version history for a workflow |
| `workflow rollback` | `<name> [hash]` | Roll back to a previous version |
### Thread Execution (`thread`)
| Subcommand | Args | Description |
|-----------|------|-------------|
| `thread run` | `<name> [--prompt <text>]` | Start a new thread for a workflow; prints thread ID |
| `thread list` | `[name]` | List threads, optionally filtered by workflow name |
| `thread show` | `<id>` | Show thread steps and state from CAS |
| `thread rm` | `<id>` | Remove a thread (from index and history) |
| `thread fork` | `<thread-id> [--from-role <role>]` | Fork from an existing thread |
| `thread ps` | | List running (active) threads |
| `thread kill` | `<thread-id>` | Send kill signal to a running thread |
| `thread live` | `<thread-id> \| --latest [--debug] [--role <name>]` | Attach and stream output live |
| `thread pause` | `<thread-id>` | Pause a running thread |
| `thread resume` | `<thread-id>` | Resume a paused thread |
### CAS Inspection (`cas`)
| Subcommand | Args | Description |
|-----------|------|-------------|
| `cas get` | `<hash>` | Print a CAS blob by hash |
| `cas put` | `<content>` | Store content in CAS, print hash |
| `cas list` | | List all hashes in CAS |
| `cas rm` | `<hash>` | Remove a CAS entry |
| `cas gc` | | Garbage-collect unreferenced entries |
### Other Commands
| Command | Args | Description |
|---------|------|-------------|
| `run <name> [...]` | | Shortcut for `thread run` |
| `live <id> [...]` | | Shortcut for `thread live` |
| `init` | | Scaffold a workflow workspace |
| `setup` | | Configure LLM providers in `workflow.yaml` |
| `connect [--name NAME] [--gateway URL]` | | Connect to gateway via WebSocket |
| `skill [topic]` | | Print agent-consumable docs (`cli`, `develop`, `author`) |
## Common Usage Examples
```bash
# Register a bundle
uncaged-workflow workflow add solve-issue dist/solve-issue.esm.js
# Run a workflow (prints thread ID)
uncaged-workflow run solve-issue --prompt "Fix the login bug in auth.ts"
# Watch live output
uncaged-workflow live <thread-id>
# Inspect a CAS blob
uncaged-workflow cas get 3TNKQRJ7BM4XH
# Show all running threads
uncaged-workflow thread ps
# Garbage-collect
uncaged-workflow cas gc
# Roll back to previous version
uncaged-workflow workflow rollback solve-issue
```
## Environment Variables
| Variable | Description |
|----------|-------------|
| `WORKFLOW_STORAGE_ROOT` | Override storage directory (default: `~/.uncaged/workflow`) |
| `UNCAGED_WORKFLOW_STORAGE_ROOT` | Internal override; takes priority over `WORKFLOW_STORAGE_ROOT` |
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command router (`COMMAND_TABLE`) |
| `@uncaged/cli-workflow` | `src/cli-usage.ts` | Usage text formatting |
| `@uncaged/cli-workflow` | `src/commands/workflow/dispatch.ts` | `WORKFLOW_SUBCOMMAND_TABLE` |
| `@uncaged/cli-workflow` | `src/commands/thread/dispatch.ts` | `THREAD_SUBCOMMAND_TABLE` |
| `@uncaged/cli-workflow` | `src/commands/cas/dispatch.ts` | `CAS_SUBCOMMAND_TABLE` |
| `@uncaged/cli-workflow` | `src/cli.ts` | CLI entry point |
## See Also
- [Bundle](./bundle.md) — what `workflow add` registers
- [Thread](./thread.md) — what `thread run` creates
- [Registry](./registry.md) — the `workflow.yaml` that `workflow` commands manage
-74
View File
@@ -1,74 +0,0 @@
# Dashboard
> A private React single-page application for browsing workflows, inspecting thread execution records, and triggering runs via a connected gateway.
## Overview
The dashboard (`workflow-dashboard`) is a read-mostly web UI that surfaces thread history and workflow metadata. It is a private package (not published to npm) and is deployed separately from the CLI. It communicates with one or more remote workflow engine instances through the `workflow-gateway` WebSocket gateway, which proxies API calls back to each connected CLI client.
The dashboard is not required to use the workflow engine — it is an optional observability layer on top of the same data that the CLI exposes.
## Tech Stack
| Concern | Choice |
|---------|--------|
| Framework | React (functional components, hooks) |
| Build | Vite |
| Styling | CSS variables via Tailwind-compatible utility classes |
| Charts/graphs | ReactFlow (workflow graph visualization) |
| HTTP | Native `fetch` with Bearer token auth |
| Transport | REST over HTTP (proxied through the gateway) |
## Data Sources
The dashboard consumes four REST endpoints per connected client (proxied by the gateway):
| Endpoint | Data |
|----------|------|
| `GET /workflows` | List of registered workflows with current hash and timestamp |
| `GET /workflows/:name` | Full workflow detail including `WorkflowDescriptor` and version history |
| `GET /threads` | All threads (active + completed) with summary fields |
| `GET /threads/:id` | Thread records: `ThreadStartRecord`, `RoleRecord[]`, `WorkflowResultRecord` |
The gateway multiplexes multiple CLI clients; the sidebar allows switching between them.
## Views
| View | Description |
|------|-------------|
| **Workflows** | Lists all registered workflows; clicking shows hash, descriptor, role graph, and version history |
| **Threads** | Lists all threads; clicking shows the full step-by-step execution record with role metadata |
| **Run dialog** | Form to start a new thread by picking a workflow and entering a prompt |
### Workflow Graph
Each workflow's `WorkflowDescriptor.graph` is rendered as an interactive ReactFlow diagram. Nodes represent roles (plus `__start__` and `__end__` terminals); edges represent moderator transitions labeled with condition names.
## Authentication
A Bearer token (stored in `localStorage` under `workflow-api-key`) is sent with every API request. The login page prompts for this key on first load. The gateway validates the token before proxying requests to connected clients.
## Gateway Connection
`uncaged-workflow connect [--name NAME] [--gateway URL]` registers the local workflow engine as a named client with the gateway over a WebSocket. The gateway then forwards REST API calls from the dashboard to the connected CLI process. The dashboard calls `GET /api/gateway/endpoints` to discover connected clients.
## Private App Status
`workflow-dashboard` has `"private": true` in its `package.json` and is excluded from the changeset versioning pipeline. It is developed alongside the engine packages but distributed separately (e.g., as a static build hosted alongside the gateway server).
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `workflow-dashboard` | `src/app.tsx` | Root component — routing, auth state, view switching |
| `workflow-dashboard` | `src/api.ts` | All API functions + endpoint types (`ThreadRecord`, `WorkflowDetail`, etc.) |
| `workflow-dashboard` | `src/components/thread-detail.tsx` | Thread step viewer |
| `workflow-dashboard` | `src/components/workflow-graph/workflow-graph.tsx` | ReactFlow graph of workflow roles and transitions |
| `workflow-dashboard` | `src/components/sidebar.tsx` | Client selector and view navigation |
| `@uncaged/workflow-gateway` | `src/index.ts` | Gateway server entry point |
| `@uncaged/workflow-gateway` | `src/ws-protocol.ts` | WebSocket message protocol between CLI and gateway |
## See Also
- [Thread](./thread.md) — the execution records the dashboard displays
- [Engine](./engine.md) — the process that produces those records
-110
View File
@@ -1,110 +0,0 @@
# Engine
> The execution loop that drives a workflow bundle's `AsyncGenerator`, persisting each yielded `RoleOutput` as a CAS `StateNode` and managing thread lifecycle.
## Overview
The engine (`executeThread`) takes a `WorkflowFn` and runs it to completion. It is responsible for three concerns: persisting each role output to CAS, updating the active-thread index after every step, and terminating the thread cleanly when the generator finishes, is aborted, or is killed by the supervisor.
The engine does not interact with LLMs directly — that responsibility belongs to the workflow bundle's `run` function and its bound agent adapters. The engine only observes `RoleOutput` values yielded by the generator.
## Execution Flow
```mermaid
flowchart TD
A[executeThread] --> B[putStartNode → CAS]
B --> C[publishHead → threads.json]
C --> D{generator.next}
D -- done --> E[finalizeThread]
D -- yield RoleOutput --> F[appendStateForStep → CAS]
F --> G[publishHead → threads.json]
G --> H{supervisorInterval?}
H -- kill --> E
H -- continue --> I{awaitAfterEachYield}
I --> D
D -- AbortSignal --> J[finalizeAbortedThread]
E --> K[removeThreadEntry]
K --> L[appendThreadHistoryEntry]
```
## Role Loop (inside the bundle's `createWorkflow`)
The `WorkflowFn` produced by `createWorkflow` runs its own loop — one iteration per role step:
1. **Moderator**: calls `pickNext(ctx)` (derived from the `ModeratorTable`) → returns a role name or `END`.
2. **Adapter**: calls the bound `AdapterFn` with the role's `systemPrompt` and Zod schema → returns `RoleFn` → executes → returns `RoleResult<T>`.
3. **Persist**: calls `putContentNodeWithRefs` to store the role output in CAS, constructs a `RoleStep`, and `yield`s a `RoleOutput` to the engine.
```mermaid
sequenceDiagram
participant E as Engine
participant W as WorkflowFn (bundle)
participant M as Moderator
participant A as AdapterFn
participant C as CAS
E->>W: generator.next()
W->>M: pickNext(ctx) → roleName
W->>A: adapter(systemPrompt, schema)(ctx, runtime)
A-->>W: RoleResult { meta, childThread }
W->>C: putContentNodeWithRefs(JSON.stringify(meta))
W-->>E: yield RoleOutput
E->>C: putStateNode(StateNodePayload)
E->>E: publishHead(threads.json)
```
## Key Types
```typescript
// Engine input
type ExecuteThreadOptions = {
depth: number;
parentStateHash: string | null;
signal: AbortSignal;
awaitAfterEachYield: () => Promise<void>; // used for pause/resume gate
forkContinuation: ForkContinuationOptions | null;
prefilledDiskSteps: PrefilledDiskStep[] | null;
replayTimestamps: readonly number[] | null;
storageRoot: string;
};
// Engine output
type WorkflowResult = {
returnCode: number;
summary: string;
rootHash: string; // hash of the __end__ StateNode
};
```
## Pause Gate
`awaitAfterEachYield` is a function injected by the worker/runner that can block the loop between steps. The `ThreadPauseGate` in `thread-pause-gate.ts` provides `pause()` / `resume()` operations that control this gate. When paused, the loop suspends after writing the current step but before requesting the next one.
## Supervisor
If `workflowConfig.supervisorInterval > 0`, the engine runs a supervisor check after every `supervisorInterval` steps. The supervisor calls an LLM with a summary of recent steps and returns `"continue"` or `"kill"`. A `"kill"` decision finalizes the thread immediately with `returnCode: 1` and a summary string.
## Summarizer
On normal completion (generator returns), the engine calls `createSummarizer` to produce a single LLM-generated summary string from recent step content. This summary replaces the bundle's raw `WorkflowCompletion.summary` in the final history record.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — main engine entry point |
| `@uncaged/workflow-execute` | `src/engine/types.ts` | `ExecuteThreadOptions`, `ExecuteThreadIo`, `ChainState`, `ThreadPauseGate` |
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` persistence, history append |
| `@uncaged/workflow-execute` | `src/engine/supervisor.ts` | Supervisor LLM check (`"continue"` / `"kill"`) |
| `@uncaged/workflow-execute` | `src/engine/summarizer.ts` | Post-completion LLM summary |
| `@uncaged/workflow-execute` | `src/engine/thread-pause-gate.ts` | Pause/resume gate |
| `@uncaged/workflow-execute` | `src/engine/worker.ts` | Worker-process entry that spawns `executeThread` in a subprocess |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | `createWorkflow` — the role loop inside the bundle |
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowFn`, `RoleOutput`, `WorkflowCompletion`, `AdvanceOutcome` |
## See Also
- [Role](./role.md) — what the moderator selects each iteration
- [Agent Binding](./agent-binding.md) — what executes a role and returns its output
- [Reactor](./reactor.md) — used internally for the extract and supervisor LLM calls
- [Thread](./thread.md) — the CAS-persisted result of running the engine
-129
View File
@@ -1,129 +0,0 @@
# Package Map
> All packages in the monorepo with their responsibilities, dependency layers, and publication status.
## Overview
The monorepo is organized as a strict dependency DAG. Each layer may only depend on layers below it. The execution stack flows from the shared protocol types at the bottom up to the CLI at the top. Agent packages and template packages are leaf nodes that depend on the runtime layer but are not depended upon by the core stack.
## Package List
| Package | Description |
|---------|-------------|
| `@uncaged/workflow-protocol` | Shared types (`ThreadContext`, `RoleDefinition`, `CasStore`, `Result`, etc.) and constants (`START`, `END`) |
| `@uncaged/workflow-runtime` | `createWorkflow`, type re-exports; primary dependency for bundle authors |
| `@uncaged/workflow-util` | Utilities: Crockford Base32, ULID, structured logger, storage paths |
| `@uncaged/workflow-reactor` | `createThreadReactor` (ReAct loop), `createLlmFn` (OpenAI-compatible LLM caller) |
| `@uncaged/workflow-cas` | `createCasStore` (filesystem CAS), XXH64 hashing, Merkle node serialization |
| `@uncaged/workflow-register` | Bundle validation, `workflow.yaml` registry read/write, model resolution |
| `@uncaged/workflow-execute` | Engine (`executeThread`), extract phase, fork, GC, `workflowAsAgent` |
| `@uncaged/cli-workflow` | `uncaged-workflow` CLI — command dispatcher for all user-facing operations |
| `@uncaged/workflow-agent-cursor` | Adapter that runs the `cursor` CLI non-interactively in a workspace |
| `@uncaged/workflow-agent-hermes` | Adapter that runs `hermes chat` (Nerve-style CLI agent) |
| `@uncaged/workflow-agent-llm` | Adapter for direct LLM chat completions |
| `@uncaged/workflow-agent-react` | Adapter with ReAct loop and file/shell tools |
| `@uncaged/workflow-util-agent` | Shared agent utilities: `buildThreadInput`, `spawnCli`, `createAgentAdapter` |
| `@uncaged/workflow-template-develop` | `develop` workflow template (planner → coder → reviewer → tester → committer) |
| `@uncaged/workflow-template-solve-issue` | `solve-issue` workflow template (preparer → developer → submitter) |
| `@uncaged/workflow-gateway` | WebSocket gateway for remote CLI-to-dashboard communication |
| `workflow-dashboard` | React dashboard (private, unpublished) — thread/workflow viewer |
## Dependency Layer Diagram
```mermaid
graph TD
subgraph Layer 0 — Protocol
P[workflow-protocol]
end
subgraph Layer 1 — Foundations
RT[workflow-runtime]
UT[workflow-util]
RX[workflow-reactor]
end
subgraph Layer 2 — Storage & Register
CAS[workflow-cas]
REG[workflow-register]
end
subgraph Layer 3 — Execute
EX[workflow-execute]
end
subgraph Layer 4 — CLI
CLI[cli-workflow]
end
subgraph Agents (leaf)
AGC[workflow-agent-cursor]
AGH[workflow-agent-hermes]
AGL[workflow-agent-llm]
AGR[workflow-agent-react]
UA[workflow-util-agent]
end
subgraph Templates (leaf)
TD[workflow-template-develop]
TS[workflow-template-solve-issue]
end
subgraph Dashboard
GW[workflow-gateway]
DB[workflow-dashboard]
end
RT --> P
UT --> P
RX --> P
CAS --> P
REG --> P
REG --> UT
EX --> RT
EX --> UT
EX --> CAS
EX --> REG
EX --> RX
CLI --> EX
CLI --> UT
CLI --> REG
AGC --> RT
AGC --> UT
AGC --> UA
AGH --> RT
AGH --> UA
AGL --> RT
AGR --> RT
AGR --> RX
UA --> RT
TD --> RT
TS --> RT
DB --> GW
```
## Published vs. Private
All `@uncaged/*` packages are published to **npmjs.org** under a fixed versioning scheme (all packages share the same version number via `@changesets/cli` in fixed mode).
| Status | Packages |
|--------|---------|
| **Published** | All packages with `@uncaged/` scope |
| **Private** | `workflow-dashboard` (no `@uncaged/` scope, `"private": true`) |
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | Root type definitions for the entire stack |
| `@uncaged/workflow-runtime` | `src/index.ts` | Public API for bundle authors |
| `@uncaged/workflow-util` | `src/index.ts` | Utility re-exports |
| `@uncaged/workflow-execute` | `src/index.ts` | Engine public API |
| `@uncaged/cli-workflow` | `src/cli-dispatch.ts` | Top-level command table |
## See Also
- [Bundle](./bundle.md) — produced by workspace authors using `@uncaged/workflow-runtime`
- [Engine](./engine.md) — the core of `@uncaged/workflow-execute`
- [Reactor](./reactor.md) — `@uncaged/workflow-reactor`
- [Registry](./registry.md) — `@uncaged/workflow-register`
- [CLI](./cli.md) — `@uncaged/cli-workflow`
-102
View File
@@ -1,102 +0,0 @@
# Reactor
> A generic ReAct (Reason + Act) loop that drives an LLM through multiple tool-call rounds until it produces structured output matching a Zod schema.
## Overview
The reactor is a reusable abstraction for LLM interactions that require tool use. It runs a multi-turn conversation loop: the LLM is presented with a user message and a set of tools, and responds either with a tool call (which the reactor dispatches and feeds back) or with a plain JSON object matching the expected schema. The loop repeats until structured output is obtained or `maxRounds` is exhausted.
The reactor is used in two places:
1. **Extract phase**`createExtract` in `@uncaged/workflow-execute` uses a CAS-backed reactor to extract typed `meta` from a role's content hash.
2. **React agent**`createReactAdapter` in `@uncaged/workflow-agent-react` uses the reactor as its execution backbone.
## createThreadReactor
```typescript
function createThreadReactor<TThread>(
config: ThreadReactorConfig<TThread>,
): ThreadReactorFn<TThread>
```
`ThreadReactorConfig` bundles:
| Field | Purpose |
|-------|---------|
| `llm` | The `LlmFn` to call each round |
| `staticTools` | Tools always available (e.g., `cas_get`) |
| `structuredToolFromSchema` | Derives a schema-specific extraction tool from the Zod schema |
| `systemPromptForStructuredTool` | Constructs the system prompt given the extraction tool name |
| `toolHandler` | Handles non-structured tool calls; receives the raw `ToolCall` and thread context |
| `maxRounds` | Hard stop after N rounds; returns `err("max_react_rounds_exceeded")` |
## Round Lifecycle
```mermaid
sequenceDiagram
participant R as Reactor
participant L as LLM
participant H as toolHandler
R->>L: messages + tools
L-->>R: response
alt plain JSON (valid schema)
R-->>R: return ok(value)
else plain JSON (invalid)
R->>L: correction message
else tool_calls
loop each call
alt structured tool
R-->>R: validate args → return ok(value)
else static tool
R->>H: toolHandler(call, thread)
H-->>R: content string
R->>L: tool result message
end
end
end
```
## LlmFn
```typescript
type LlmFn = (input: {
messages: ChatMessage[];
tools: readonly ToolDefinition[];
}) => Promise<Result<string, string>>;
```
`createLlmFn(provider)` in `@uncaged/workflow-reactor` builds an `LlmFn` that calls the OpenAI-compatible chat completions endpoint and returns the raw response body as a string for the reactor to parse.
## Extract Phase
`createExtract(provider, { cas })` in `@uncaged/workflow-execute` creates a `CasReactor` — a preconfigured `ThreadReactorFn` with a `cas_get` static tool. The extract function loads the content payload for a given hash, sends it to the reactor with the role's Zod schema, and returns `ExtractResult<T>`.
```typescript
type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
contentHash: string,
) => Promise<ExtractResult<T>>;
```
The `cas_get` tool allows the LLM to dereference CAS hashes during extraction — important when the content node references artifact hashes.
## Relationship to Engine
The reactor is called within `AdapterFn` implementations (e.g., `createLlmAdapter`, `createReactAdapter`) when the agent needs multi-turn tool interaction to complete a role. The engine itself does not call the reactor directly — it only drives the outer `WorkflowFn` generator and persists `RoleOutput` values.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-reactor` | `src/thread-reactor.ts` | `createThreadReactor` — generic ReAct loop |
| `@uncaged/workflow-reactor` | `src/llm-fn.ts` | `createLlmFn` — OpenAI-compatible LLM caller |
| `@uncaged/workflow-reactor` | `src/types.ts` | `LlmFn`, `ThreadReactorConfig`, `ToolCall`, `ToolDefinition`, `ChatMessage` |
| `@uncaged/workflow-execute` | `src/cas-reactor.ts` | `createCasReactor` — reactor with `cas_get` static tool |
| `@uncaged/workflow-execute` | `src/extract/extract-fn.ts` | `createExtract` — extract phase using the CAS reactor |
## See Also
- [Engine](./engine.md) — drives the workflow generator; extract is called inside the adapter layer
- [Agent Binding](./agent-binding.md) — adapter implementations that use the reactor internally
-95
View File
@@ -1,95 +0,0 @@
# Registry
> `workflow.yaml` — the local file that maps workflow names to their current and historical bundle hashes, plus global LLM provider configuration.
## Overview
The registry is a single YAML file at `<storageRoot>/workflow.yaml` (default: `~/.uncaged/workflow/workflow.yaml`). It is the authoritative index of which bundles are available on a machine and what name each one is known by. All CLI workflow commands read or write this file.
The registry is read on every `uncaged-workflow run` invocation to look up the bundle hash for a given name, then used again to resolve the `extract` model configuration. It is written atomically via the `writeWorkflowRegistry` function.
## Schema
```yaml
config:
maxDepth: 3
supervisorInterval: 5
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKey: "sk-or-..."
models:
extract: "openrouter/anthropic/claude-sonnet-4-5"
supervisor: "openrouter/anthropic/claude-haiku-3-5"
workflows:
solve-issue:
hash: "3TNKQRJ7BM4XH"
timestamp: 1716000000000
history:
- hash: "2BMJPQ6YAK3WG"
timestamp: 1715000000000
develop:
hash: "7VQWX8NRHK1ZT"
timestamp: 1716100000000
history: []
```
## Types
```typescript
type WorkflowRegistryFile = {
config: WorkflowConfig | null;
workflows: Record<string, WorkflowRegistryEntry>;
};
type WorkflowRegistryEntry = {
hash: string; // current bundle hash (13-char Crockford Base32)
timestamp: number; // Unix epoch ms when this version was registered
history: WorkflowHistoryEntry[];
};
type WorkflowHistoryEntry = {
hash: string;
timestamp: number;
};
```
## Bundle Registration Flow
1. `uncaged-workflow workflow add <name> <file.esm.js>` is called.
2. The bundle bytes are hashed with XXH64 → 13-char Crockford Base32.
3. The bundle file is copied into `<storageRoot>/bundles/<hash>/` (if not already present).
4. `registerWorkflowVersion` prepends the current head to `history` and sets the new hash as head.
5. The updated registry is written back to `workflow.yaml`.
## Version History
Every `workflow add` on an already-registered name pushes the previous hash into `history`. History is ordered most-recent-first. `workflow rollback <name> [hash]` swaps the specified history entry back to head (or defaults to `history[0]`).
## Model Resolution
The `config.models` section uses `provider/model` references (e.g., `"openrouter/anthropic/claude-sonnet-4-5"`). `resolveModel` splits the reference on the first `/`, looks up the provider in `config.providers`, and returns a `ResolvedModel` with `{ baseUrl, apiKey, model }`. This is used by the engine to configure the `extract` LLM.
```typescript
// packages/workflow-register/src/config/resolve-model.ts
export function resolveModel(
config: WorkflowConfig,
modelKey: string,
): Result<ResolvedModel, string>
```
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-register` | `src/registry/registry.ts` | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `rollbackWorkflowToHistoryHash` |
| `@uncaged/workflow-register` | `src/registry/types.ts` | `WorkflowRegistryFile`, `WorkflowRegistryEntry`, `WorkflowHistoryEntry` |
| `@uncaged/workflow-register` | `src/registry/registry-normalize.ts` | YAML normalization for the registry root |
| `@uncaged/workflow-register` | `src/config/resolve-model.ts` | `resolveModel` — splits `provider/model` refs |
| `@uncaged/workflow-register` | `src/bundle/extract-bundle-exports.ts` | Validates bundle exports before registration |
| `@uncaged/workflow-protocol` | `src/types.ts` | `WorkflowConfig`, `ProviderConfig`, `ResolvedModel` |
## See Also
- [Bundle](./bundle.md) — what is stored and indexed in the registry
-72
View File
@@ -1,72 +0,0 @@
# Role
> A named actor within a workflow defined entirely as pure data — a description, a system prompt, an extraction schema, and an optional refs extractor — with no embedded agent logic.
## Overview
A role is a `RoleDefinition<Meta>` value: a plain TypeScript object that describes what an actor in the workflow does and how its output should be structured. Roles are authored in the template or bundle source and passed to `createWorkflow` as part of the `WorkflowDefinition`. They never hold a reference to an agent implementation.
This separation of concerns is deliberate. The same role definition can be executed by different agents (Cursor, Hermes, an LLM, a React loop) simply by changing the `AdapterBinding` passed to `createWorkflow`. Roles are also serialized into the `WorkflowDescriptor` for tooling like the dashboard.
## RoleDefinition Type
```typescript
type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
};
```
| Field | Purpose |
|-------|---------|
| `description` | Human-readable summary for tooling and the `WorkflowDescriptor` |
| `systemPrompt` | Passed to the adapter as the agent's persona/instruction for this role |
| `schema` | Zod v4 schema that defines the structured output (`Meta`) of the role |
| `extractRefs` | Optional function that extracts CAS hashes from `meta` to record as artifact refs |
## Schema and Extraction
Each role's `schema` is a Zod v4 type parameterized to the role's `Meta` type. When a role executes via an `AdapterFn`, the adapter is responsible for producing a value that satisfies this schema directly (the `AdapterFn` receives the schema and system prompt and returns a `RoleFn` that yields `RoleResult<T>`).
If `extractRefs` is non-null, the engine calls it on the completed `meta` to collect additional CAS hashes that should appear in the `StateNode.refs` skip-list, enabling traversal of artifacts produced by the role.
## WorkflowDefinition
Roles are collected into a `WorkflowDefinition<M>` alongside the moderator table:
```typescript
type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
table: ModeratorTable<M>;
};
```
`M` is the `RoleMeta` map that binds each role name to its concrete `Meta` type. This gives full TypeScript type safety across the moderator, adapter, and CAS storage layers.
## WorkflowRoleDescriptor (Serialized)
The `WorkflowDescriptor` (stored in the bundle's `descriptor` export) contains a `roles` map of `WorkflowRoleDescriptor` objects — a JSON-serializable projection of each `RoleDefinition`:
```typescript
type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: WorkflowRoleSchema; // JSON-compatible schema shape
};
```
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `RoleDefinition`, `WorkflowDefinition`, `RoleMeta`, `WorkflowRoleDescriptor`, `WorkflowDescriptor` |
| `@uncaged/workflow-runtime` | `src/create-workflow.ts` | Consumes `WorkflowDefinition` roles in the adapter dispatch loop |
| `@uncaged/workflow-register` | `src/bundle/build-descriptor.ts` | Serializes `RoleDefinition[]` to `WorkflowDescriptor` |
## See Also
- [Engine](./engine.md) — the loop that selects and executes roles
- [Agent Binding](./agent-binding.md) — the runtime binding that executes a role via a concrete agent
-97
View File
@@ -1,97 +0,0 @@
# Thread
> A single execution instance of a workflow, identified by a ULID, whose state is stored as a linked chain of immutable CAS nodes.
## Overview
A thread is the runtime envelope around one call to a workflow's `run` function. It carries a unique ULID (26-char Crockford Base32) and tracks the full sequence of role steps that have executed. Because all state is written to CAS as immutable blobs, threads are append-only and fully auditable.
Every thread belongs to a specific workflow bundle (identified by hash). The engine writes a `StartNode` when the thread begins and one `StateNode` per completed role step — including a final `__end__` state on completion or abort. Steps accumulate in `ThreadContext.steps` and are replayed into the context whenever a thread is resumed.
## Lifecycle
```mermaid
stateDiagram-v2
[*] --> Active: thread run / fork
Active --> Active: role step yielded
Active --> Paused: pause signal
Paused --> Active: resume signal
Active --> Completed: generator returns WorkflowCompletion
Active --> Aborted: kill signal / AbortSignal
Completed --> [*]: entry in history/*.jsonl
Aborted --> [*]: entry in history/*.jsonl (returnCode=130)
```
## Identity
Thread IDs are ULIDs: 26-char Crockford Base32 strings composed of a 10-char timestamp prefix and a 16-char random suffix. Generated by `generateUlid` from `@uncaged/workflow-util`.
## State Storage
Thread state is stored entirely in CAS as a linked list of nodes:
```
StartNode (type: "start")
payload: { name, hash, depth, parentState }
refs: [promptHash, parentState?]
StateNode (type: "state") ← one per role step
payload: { role, meta, start, content, ancestors[], compact, timestamp, childThread }
refs: [contentHash, startHash, ancestor hashes...]
StateNode (type: "state", role: "__end__") ← final node
payload: { returnCode, summary }
```
The `ancestors` array implements a skip-list (capped at 11 entries: 1 direct parent + up to 10 ancestors) to allow efficient traversal without loading every node in the chain.
## Index Files
| File | Purpose |
|------|---------|
| `<bundleDir>/threads.json` | Active thread index — maps `threadId → { head, start, updatedAt }` |
| `<bundleDir>/history/YYYY-MM-DD.jsonl` | Completed thread records — one JSON line per completed/aborted thread |
| `<storageRoot>/cas/` | All CAS blobs shared across all bundles |
A thread is "active" while it appears in `threads.json`. On completion, its entry is removed from `threads.json` and a record appended to the appropriate `history/*.jsonl` file.
## ThreadContext
The `ThreadContext` type is the read-only view passed into every role and moderator call:
```typescript
type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
bundleHash: string;
start: StartStep;
steps: RoleStep<M>[];
};
```
`depth` tracks nesting for sub-workflow invocations (workflow-as-agent). `steps` grows by one entry after each successful role execution.
## Fork
A thread can be forked from any completed role step via `thread fork <id> [--from-role <role>]`. The fork reuses the original `StartNode` (same `startHash`) and replays CAS steps up to the fork point before resuming the generator. The forked thread gets a new ULID.
## Debug Logs
Each thread writes structured JSONL debug logs to `.info.jsonl` in the bundle directory. Each log line is `{ tag, content, timestamp }` where `tag` is an 8-char Crockford Base32 call-site identifier.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-protocol` | `src/types.ts` | `ThreadContext`, `StartStep`, `RoleStep`, `RoleMeta` types |
| `@uncaged/workflow-protocol` | `src/cas-types.ts` | `StartNode`, `StartNodePayload`, `StateNode`, `StateNodePayload` |
| `@uncaged/workflow-execute` | `src/engine/threads-index.ts` | `threads.json` read/write, history append, `ThreadIndexEntry` |
| `@uncaged/workflow-execute` | `src/engine/engine.ts` | `executeThread` — starts, drives, and finalizes a thread |
| `@uncaged/workflow-execute` | `src/engine/fork-thread.ts` | Fork logic |
| `@uncaged/workflow-util` | `src/ulid.ts` | `generateUlid` — ULID generation |
## See Also
- [CAS](./cas.md) — the storage layer that holds all thread state nodes
- [Engine](./engine.md) — the execution loop that drives the thread
- [Bundle](./bundle.md) — the workflow being executed in this thread
-153
View File
@@ -1,153 +0,0 @@
# Workflow Templates
> Pre-built `WorkflowDefinition` objects exported from `@uncaged/workflow-template-*` packages that bundle authors can import, customize, or use directly.
## Overview
Templates are the reference implementations of common workflow patterns. They export a complete `WorkflowDefinition<M>` — typed roles with Zod schemas, and a `ModeratorTable` — ready to be passed to `createWorkflow`. A bundle author imports a template definition, supplies an `AdapterBinding`, calls `createWorkflow`, and exports the result as `run`.
Templates are published as regular `@uncaged/*` npm packages. They are not bundles themselves; they are TypeScript libraries that become part of a bundle when the author's workspace is built.
## solve-issue Template
**Package**: `@uncaged/workflow-template-solve-issue`
Resolves an issue end-to-end by preparing the repository, delegating implementation to a nested `develop` workflow, and opening a pull request.
### Roles
| Role | Description |
|------|-------------|
| `preparer` | Reads the issue, clones/checks out the repo, sets up the environment |
| `developer` | Delegates to the `develop` workflow via `workflowAsAgent` (child thread) |
| `submitter` | Opens a pull request with the completed changes |
### Moderator Table
```
__start__ → preparer → developer → submitter → __end__
```
Linear routing — each role runs exactly once in sequence.
### Meta Types
```typescript
type SolveIssueMeta = {
preparer: PreparerMeta;
developer: DeveloperMeta;
submitter: SubmitterMeta;
};
```
## develop Template
**Package**: `@uncaged/workflow-template-develop`
Plans an implementation in phases, codes each phase incrementally, reviews, verifies with tests/build/lint, and commits.
### Roles
| Role | Description |
|------|-------------|
| `planner` | Produces an ordered list of implementation phases with hashes |
| `coder` | Implements one phase; reports `completedPhase` hash in meta |
| `reviewer` | Reviews the accumulated changes; approves or requests changes |
| `tester` | Runs tests/lint/build; reports `passed` or `failed` |
| `committer` | Creates the final git commit |
### Moderator Table
```
__start__ → planner
planner → __end__ (if status == "aborted")
planner → coder (fallback)
coder → reviewer (if allPhasesComplete)
coder → coder (fallback — repeat per phase)
reviewer → tester (if status == "approved")
reviewer → coder (fallback — request changes)
tester → committer (if status == "passed")
tester → coder (fallback — fix failures)
committer → __end__
```
### Meta Types
```typescript
type DevelopMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
```
## Writing a Custom Template
A minimal custom workflow:
```typescript
import { createWorkflow, type WorkflowDefinition, END, START } from "@uncaged/workflow-runtime";
import { z } from "zod/v4";
import type { AdapterBinding } from "@uncaged/workflow-runtime";
type MyMeta = {
analyst: { summary: string; confidence: number };
writer: { report: string };
};
const def: WorkflowDefinition<MyMeta> = {
description: "Analyse then write a report.",
roles: {
analyst: {
description: "Analyses the input and produces a structured summary.",
systemPrompt: "You are an expert analyst...",
schema: z.object({ summary: z.string(), confidence: z.number() }),
extractRefs: null,
},
writer: {
description: "Writes the final report.",
systemPrompt: "You are a technical writer...",
schema: z.object({ report: z.string() }),
extractRefs: null,
},
},
table: {
[START]: [{ condition: "FALLBACK", role: "analyst" }],
analyst: [{ condition: "FALLBACK", role: "writer" }],
writer: [{ condition: "FALLBACK", role: END }],
},
};
// In the bundle entry point:
export const run = createWorkflow(def, binding);
export const descriptor = buildDescriptor(def);
```
## Template → Bundle Relationship
Templates are TypeScript library packages, not bundles. To use a template:
1. Install the template package from npm: `bun add @uncaged/workflow-template-develop`.
2. Import the definition: `import { developWorkflowDefinition } from "@uncaged/workflow-template-develop"`.
3. Supply an `AdapterBinding` and call `createWorkflow`.
4. Build with `bun build` to produce `.esm.js`.
5. Register with `uncaged-workflow workflow add`.
## Code Pointers
| Package | File | What it does |
|---------|------|-------------|
| `@uncaged/workflow-template-solve-issue` | `src/index.ts` | `solveIssueWorkflowDefinition`, role and moderator exports |
| `@uncaged/workflow-template-solve-issue` | `src/roles.ts` | `SolveIssueMeta`, `solveIssueRoles` |
| `@uncaged/workflow-template-solve-issue` | `src/moderator.ts` | `solveIssueTable` — linear transition table |
| `@uncaged/workflow-template-develop` | `src/index.ts` | `developWorkflowDefinition`, role and moderator exports |
| `@uncaged/workflow-template-develop` | `src/roles.ts` | `DevelopMeta`, `developRoles` |
| `@uncaged/workflow-template-develop` | `src/moderator.ts` | `developTable` — conditional multi-phase table |
## See Also
- [Bundle](./bundle.md) — the build artifact produced from a template + adapter
- [Role](./role.md) — the `RoleDefinition` type each template role implements
- [Engine](./engine.md) — the execution loop that drives the template's `WorkflowFn`
-4
View File
@@ -4,10 +4,6 @@
"workspaces": [
"packages/*"
],
"overrides": {
"@uncaged/json-cas": "^0.1.0",
"@uncaged/json-cas-workflow": "^0.1.0"
},
"scripts": {
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
@@ -20,6 +20,9 @@ import { addCliArgs } from "./bundle-fixture.js";
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
`;
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
@@ -49,12 +52,12 @@ describe("cli workflow commands", () => {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}import fs from "node:fs";
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
export const run = async function* (input, options) {
fs.existsSync(".");
const cas = options.cas;
const h = await cas.put(input.prompt);
const h = await putContentMerkleNode(cas, input.prompt);
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
return { returnCode: 0, summary: "done" };
}
@@ -152,9 +155,10 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
},
graph: { edges: [] },
};
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
const h = await cas.put( input.prompt);
const h = await putContentMerkleNode(cas, input.prompt);
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
return { returnCode: 0, summary: "ok" };
};
@@ -193,9 +197,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "x");
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -224,9 +228,9 @@ export const run = async function* (input, options) {
const dtsPath = join(bundleDir, "types.d.ts");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "x");
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -257,9 +261,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "x");
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -280,16 +284,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "v1");
const h = await putContentMerkleNode(cas, "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "v2");
const h = await putContentMerkleNode(cas, "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -322,16 +326,16 @@ export const run = async function* (input, options) {
const bundleDir = join(storageRoot, "src");
await mkdir(bundleDir, { recursive: true });
const bundlePath = join(bundleDir, "demo.esm.js");
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "v1");
const h = await putContentMerkleNode(cas, "v1");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v1" };
}
`;
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "v2");
const h = await putContentMerkleNode(cas, "v2");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "v2" };
}
@@ -374,9 +378,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "x");
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -387,9 +391,9 @@ export const run = async function* (input, options) {
expect(add1.ok).toBe(true);
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "y");
const h = await putContentMerkleNode(cas, "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -442,9 +446,9 @@ export const run = async function* (input, options) {
const bundlePath = join(bundleDir, "demo.esm.js");
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "x");
const h = await putContentMerkleNode(cas, "x");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "x" };
}
@@ -459,9 +463,9 @@ export const run = async function* (input, options) {
const hash1 = add1.value.hash;
await writeFile(
bundlePath,
`${fixtureDescriptor}export const run = async function* (_input, options) {
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
const cas = options.cas;
const h = await cas.put( "y");
const h = await putContentMerkleNode(cas, "y");
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "y" };
}
@@ -15,7 +15,9 @@ import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `export const descriptor = {
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
export const descriptor = {
description: "fork-cli",
roles: {
planner: { description: "planner", schema: {} },
@@ -28,16 +30,16 @@ export const run = async function* (input, options) {
const cas = options.cas;
const has = (r) => input.steps.some((s) => s.role === r);
if (!has("planner")) {
const h = await cas.put( "p1");
const h = await putContentMerkleNode(cas, "p1");
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
}
if (!has("coder")) {
const h = await cas.put( "c1");
const h = await putContentMerkleNode(cas, "c1");
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
}
if (!has("reviewer")) {
const body = "rev-" + String(input.steps.length);
const h = await cas.put( body);
const h = await putContentMerkleNode(cas, body);
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
}
return { returnCode: 0, summary: "done" };
@@ -1,183 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
cmdJsonCasInit,
cmdNodeGet,
cmdNodeList,
cmdNodeWalk,
cmdWorkflowRegister,
cmdWorkflowShow,
formatNodeWalk,
formatWorkflowShow,
getJsonCasDir,
} from "../src/commands/json-cas/index.js";
const SIMPLE_WORKFLOW = {
name: "test-workflow",
description: "A test workflow for CLI tests",
roles: {
analyst: {
description: "Analyses the input",
systemPrompt: "You are an analyst.",
extractPrompt: "Extract the analysis.",
schema: { type: "object", properties: { result: { type: "string" } } },
},
},
moderator: [{ from: "analyst", to: "__end__", when: null }],
};
describe("json-cas CLI commands", () => {
let storageRoot: string;
beforeEach(async () => {
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-json-cas-"));
});
afterEach(async () => {
await rm(storageRoot, { recursive: true, force: true });
});
test("getJsonCasDir returns path under storageRoot", () => {
const dir = getJsonCasDir(storageRoot);
expect(dir).toBe(join(storageRoot, "json-cas"));
});
test("init bootstraps the store and returns a workflow type hash", async () => {
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
expect(typeof workflowTypeHash).toBe("string");
expect(workflowTypeHash.length).toBe(13);
});
test("init is idempotent", async () => {
const hash1 = await cmdJsonCasInit(storageRoot);
const hash2 = await cmdJsonCasInit(storageRoot);
expect(hash1).toBe(hash2);
});
test("workflow register returns a hash", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const result = await cmdWorkflowRegister(storageRoot, filePath);
expect(typeof result.hash).toBe("string");
expect(result.hash.length).toBe(13);
});
test("workflow register is idempotent", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const r1 = await cmdWorkflowRegister(storageRoot, filePath);
const r2 = await cmdWorkflowRegister(storageRoot, filePath);
expect(r1.hash).toBe(r2.hash);
});
test("workflow show loads a registered workflow", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const wf = await cmdWorkflowShow(storageRoot, hash);
expect(wf).not.toBeNull();
if (wf === null) return;
expect(wf.name).toBe("test-workflow");
expect(wf.description).toBe("A test workflow for CLI tests");
expect(Object.keys(wf.roles)).toContain("analyst");
expect(wf.roles.analyst.systemPrompt).toBe("You are an analyst.");
expect(wf.moderator).toHaveLength(1);
expect(wf.moderator[0].from).toBe("analyst");
});
test("workflow show returns null for unknown hash", async () => {
await cmdJsonCasInit(storageRoot);
const result = await cmdWorkflowShow(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("formatWorkflowShow produces expected output", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const wf = await cmdWorkflowShow(storageRoot, hash);
if (wf === null) throw new Error("workflow not found");
const output = formatWorkflowShow(hash, wf);
expect(output).toContain("test-workflow");
expect(output).toContain(hash);
expect(output).toContain("analyst");
expect(output).toContain("moderator:");
});
test("node list returns hashes after registration", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
await cmdWorkflowRegister(storageRoot, filePath);
const hashes = await cmdNodeList(storageRoot);
expect(hashes.length).toBeGreaterThan(0);
});
test("node list returns empty array for empty store", async () => {
const hashes = await cmdNodeList(storageRoot);
expect(hashes).toEqual([]);
});
test("node get returns JSON for a known hash", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const json = await cmdNodeGet(storageRoot, hash);
expect(json).not.toBeNull();
if (json === null) return;
const parsed = JSON.parse(json) as unknown;
expect(parsed).toMatchObject({ payload: expect.anything() });
});
test("node get returns null for unknown hash", async () => {
const result = await cmdNodeGet(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("node walk traverses the workflow DAG", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const entries = await cmdNodeWalk(storageRoot, hash);
expect(entries).not.toBeNull();
if (entries === null) return;
// should include at least the workflow node, role node, and role-schema node
expect(entries.length).toBeGreaterThanOrEqual(3);
const hashes = entries.map((e) => e.hash);
expect(hashes).toContain(hash);
});
test("node walk returns null for unknown root", async () => {
const result = await cmdNodeWalk(storageRoot, "AAAAAAAAAAAAA");
expect(result).toBeNull();
});
test("formatNodeWalk produces output with node hashes", async () => {
const filePath = join(storageRoot, "wf.json");
await writeFile(filePath, JSON.stringify(SIMPLE_WORKFLOW), "utf-8");
const { hash } = await cmdWorkflowRegister(storageRoot, filePath);
const entries = await cmdNodeWalk(storageRoot, hash);
if (entries === null) throw new Error("walk failed");
const output = formatNodeWalk(hash, entries);
expect(output).toContain(`walk from: ${hash}`);
expect(output).toContain(hash);
});
});
@@ -23,6 +23,9 @@ import { resolveThreadRecord } from "../src/thread-scan.js";
import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
`;
const threadFixtureDescriptor = `export const descriptor = {
description: "thread-cli",
roles: {
@@ -38,23 +41,25 @@ const threadFixtureDescriptor = `export const descriptor = {
`;
const fastBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await cas.put( "plan");
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await cas.put( "code");
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const slowPlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
await new Promise((r) => setTimeout(r, 400));
const cas = options.cas;
let h = await cas.put( "plan");
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
h = await cas.put( "code");
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
@@ -63,34 +68,37 @@ export const run = async function* (input, options) {
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (input, options) {
const cas = options.cas;
let h = await cas.put( "plan");
let h = await putContentMerkleNode(cas, "plan");
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
await new Promise((r) => setTimeout(r, 10000));
h = await cas.put( "code");
h = await putContentMerkleNode(cas, "code");
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const pauseResumeBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
const cas = options.cas;
let h = await cas.put( "f");
let h = await putContentMerkleNode(cas, "f");
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
await new Promise((r) => setTimeout(r, 1500));
h = await cas.put( "s");
h = await putContentMerkleNode(cas, "s");
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
`;
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
${wfPutImport}
export const run = async function* (_input, options) {
await new Promise((r) => setTimeout(r, 900));
const cas = options.cas;
const h = await cas.put( "x");
const h = await putContentMerkleNode(cas, "x");
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
return { returnCode: 0, summary: "done" };
};
+3 -7
View File
@@ -11,17 +11,13 @@
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.1.1",
"@uncaged/json-cas-fs": "^0.1.1",
"@uncaged/json-cas-workflow": "^0.1.1",
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-json-def": "workspace:*",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
@@ -5,7 +5,6 @@ import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { dispatchConnect } from "./commands/connect/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { createJsonCasDispatcher } from "./commands/json-cas/index.js";
import { dispatchSetup } from "./commands/setup/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
@@ -44,7 +43,6 @@ const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
const dispatchThread = createThreadDispatcher({ dispatchGroup });
const dispatchCas = createCasDispatcher({ dispatchGroup });
const dispatchInit = createInitDispatcher({ dispatchGroup });
const dispatchJsonCas = createJsonCasDispatcher({ dispatchGroup });
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
if (topic === undefined) {
@@ -74,7 +72,6 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
run: dispatchRun,
live: dispatchLive,
connect: dispatchConnect,
"json-cas": dispatchJsonCas,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
@@ -2,7 +2,6 @@ import type { CommandGroup } from "./cli-command-types.js";
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
import { JSON_CAS_SUBCOMMAND_TABLE } from "./commands/json-cas/index.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
@@ -53,14 +52,6 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
name: "setup",
commands: [...SETUP_USAGE_COMMANDS],
},
{
name: "json-cas",
commands: Object.entries(JSON_CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
name,
args: e.args,
description: e.description,
})),
},
];
}
-1
View File
@@ -13,7 +13,6 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
cas: "Content-addressable storage:",
init: "Development:",
setup: "Configuration:",
"json-cas": "JSON-CAS engine:",
};
export function formatUsageCommandLines(
@@ -51,6 +51,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
description: "Says hello — replace with your first role.",
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
schema: greeterMetaSchema,
extractRefs: null,
};
`;
}
@@ -1,227 +0,0 @@
import type { CommandEntry } from "../../cli-command-types.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { cmdJsonCasInit } from "./init.js";
import { cmdNodeGet } from "./node-get.js";
import { cmdNodeList } from "./node-list.js";
import { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
import { getJsonCasDir } from "./store.js";
import { cmdThreadShow, formatThreadShow } from "./thread-show.js";
import type { JsonCasDispatchDeps } from "./types.js";
import { cmdWorkflowRegister } from "./workflow-register.js";
import { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
// ── node subcommands ─────────────────────────────────────────────────────────
export async function dispatchNodeGet(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas node get requires <hash>");
return 1;
}
const result = await cmdNodeGet(storageRoot, hash);
if (result === null) {
printCliError(`error: node not found: ${hash}`);
return 1;
}
printCliLine(result);
return 0;
}
export async function dispatchNodeList(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError("error: json-cas node list takes no arguments");
return 1;
}
const hashes = await cmdNodeList(storageRoot);
if (hashes.length === 0) {
printCliLine("(no nodes)");
return 0;
}
for (const hash of hashes) {
printCliLine(hash);
}
return 0;
}
export async function dispatchNodeWalk(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas node walk requires <hash>");
return 1;
}
const entries = await cmdNodeWalk(storageRoot, hash);
if (entries === null) {
printCliError(`error: node not found: ${hash}`);
return 1;
}
printCliLine(formatNodeWalk(hash, entries));
return 0;
}
export const JSON_CAS_NODE_TABLE: Record<string, CommandEntry> = {
get: { handler: dispatchNodeGet, args: "<hash>", description: "Get a CAS node as JSON" },
list: { handler: dispatchNodeList, args: "", description: "List all hashes in the store" },
walk: {
handler: dispatchNodeWalk,
args: "<hash>",
description: "Walk the DAG from a node, show referenced nodes",
},
};
// ── workflow subcommands ─────────────────────────────────────────────────────
export async function dispatchWorkflowRegister(
storageRoot: string,
argv: string[],
): Promise<number> {
const file = argv[0];
if (file === undefined || argv.length > 1) {
printCliError("error: json-cas workflow register requires <file.json>");
return 1;
}
const result = await cmdWorkflowRegister(storageRoot, file);
printCliLine(`registered workflow: ${result.hash}`);
return 0;
}
export async function dispatchWorkflowShow(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas workflow show requires <hash>");
return 1;
}
const wf = await cmdWorkflowShow(storageRoot, hash);
if (wf === null) {
printCliError(`error: workflow not found: ${hash}`);
return 1;
}
printCliLine(formatWorkflowShow(hash, wf));
return 0;
}
export const JSON_CAS_WORKFLOW_TABLE: Record<string, CommandEntry> = {
register: {
handler: dispatchWorkflowRegister,
args: "<file.json>",
description: "Register a workflow definition from a JSON file",
},
show: {
handler: dispatchWorkflowShow,
args: "<hash>",
description: "Show a workflow by its CAS hash",
},
};
// ── thread subcommands ───────────────────────────────────────────────────────
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const hash = argv[0];
if (hash === undefined || argv.length > 1) {
printCliError("error: json-cas thread show requires <start-hash>");
return 1;
}
const result = await cmdThreadShow(storageRoot, hash);
if (result === null) {
printCliError(`error: thread start node not found: ${hash}`);
return 1;
}
printCliLine(formatThreadShow(result));
return 0;
}
export const JSON_CAS_THREAD_TABLE: Record<string, CommandEntry> = {
show: {
handler: dispatchThreadShow,
args: "<start-hash>",
description: "Walk and display thread steps from a thread-start hash",
},
};
// ── top-level json-cas subcommands ───────────────────────────────────────────
export async function dispatchJsonCasInit(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError("error: json-cas init takes no arguments");
return 1;
}
const workflowTypeHash = await cmdJsonCasInit(storageRoot);
const dir = getJsonCasDir(storageRoot);
printCliLine(`initialized json-cas store at ${dir}`);
printCliLine(`workflow type hash: ${workflowTypeHash}`);
return 0;
}
function printSubgroupHelp(
groupPath: string,
table: Record<string, CommandEntry>,
sub: string | undefined,
): number {
if (sub === undefined || sub === "--help" || sub === "-h") {
const lines = [`json-cas ${groupPath} subcommands:\n`];
for (const [name, e] of Object.entries(table)) {
const args = e.args ? ` ${e.args}` : "";
lines.push(` uncaged-workflow json-cas ${groupPath} ${name}${args}`);
lines.push(` ${e.description}\n`);
}
printCliLine(lines.join("\n"));
return sub === undefined ? 1 : 0;
}
return -1;
}
async function dispatchSubgroup(
groupPath: string,
table: Record<string, CommandEntry>,
storageRoot: string,
argv: string[],
): Promise<number> {
const sub = argv[0];
const helpResult = printSubgroupHelp(groupPath, table, sub);
if (helpResult >= 0) return helpResult;
const entry = table[sub as string];
if (entry === undefined) {
printCliError(`error: unknown json-cas ${groupPath} subcommand: ${sub}`);
return 1;
}
return entry.handler(storageRoot, argv.slice(1));
}
export const JSON_CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
init: {
handler: dispatchJsonCasInit,
args: "",
description: "Initialize json-cas store and register workflow schemas",
},
workflow: {
handler: (storageRoot, argv) =>
dispatchSubgroup("workflow", JSON_CAS_WORKFLOW_TABLE, storageRoot, argv),
args: "<register|show>",
description: "Manage json-cas workflow definitions",
},
thread: {
handler: (storageRoot, argv) =>
dispatchSubgroup("thread", JSON_CAS_THREAD_TABLE, storageRoot, argv),
args: "<show>",
description: "Inspect json-cas thread execution records",
},
node: {
handler: (storageRoot, argv) =>
dispatchSubgroup("node", JSON_CAS_NODE_TABLE, storageRoot, argv),
args: "<get|list|walk>",
description: "Low-level access to json-cas store nodes",
},
};
export function createJsonCasDispatcher(deps: JsonCasDispatchDeps) {
const { dispatchGroup } = deps;
return async function dispatchJsonCas(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("json-cas", JSON_CAS_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) {
return result;
}
const sub = argv[0];
printCliError(`error: unknown json-cas subcommand: ${sub}`);
return 1;
};
}
@@ -1,25 +0,0 @@
export {
createJsonCasDispatcher,
dispatchJsonCasInit,
dispatchNodeGet,
dispatchNodeList,
dispatchNodeWalk,
dispatchThreadShow,
dispatchWorkflowRegister,
dispatchWorkflowShow,
JSON_CAS_NODE_TABLE,
JSON_CAS_SUBCOMMAND_TABLE,
JSON_CAS_THREAD_TABLE,
JSON_CAS_WORKFLOW_TABLE,
} from "./dispatch.js";
export { cmdJsonCasInit } from "./init.js";
export { cmdNodeGet } from "./node-get.js";
export { cmdNodeList } from "./node-list.js";
export { cmdNodeWalk, formatNodeWalk } from "./node-walk.js";
export { getJsonCasDir, openStore } from "./store.js";
export type { ThreadShowResult, ThreadStep } from "./thread-show.js";
export { cmdThreadShow, formatThreadShow } from "./thread-show.js";
export type { JsonCasDispatchDeps } from "./types.js";
export type { WorkflowRegisterResult } from "./workflow-register.js";
export { cmdWorkflowRegister } from "./workflow-register.js";
export { cmdWorkflowShow, formatWorkflowShow } from "./workflow-show.js";
@@ -1,6 +0,0 @@
import { openStore } from "./store.js";
export async function cmdJsonCasInit(storageRoot: string): Promise<string> {
const { typeHashes } = await openStore(storageRoot);
return typeHashes.workflow;
}
@@ -1,9 +0,0 @@
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export async function cmdNodeGet(storageRoot: string, hash: string): Promise<string | null> {
const store = createFsStore(getJsonCasDir(storageRoot));
const node = store.get(hash);
if (node === null) return null;
return JSON.stringify(node, null, 2);
}
@@ -1,7 +0,0 @@
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export async function cmdNodeList(storageRoot: string): Promise<string[]> {
const store = createFsStore(getJsonCasDir(storageRoot));
return store.list();
}
@@ -1,50 +0,0 @@
import type { CasNode, Hash } from "@uncaged/json-cas";
import { walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import { getJsonCasDir } from "./store.js";
export type WalkEntry = {
hash: Hash;
type: Hash;
timestamp: number;
payloadPreview: string;
};
export async function cmdNodeWalk(
storageRoot: string,
rootHash: string,
): Promise<WalkEntry[] | null> {
const store = createFsStore(getJsonCasDir(storageRoot));
if (!store.has(rootHash)) return null;
const entries: WalkEntry[] = [];
walk(store, rootHash, (hash: Hash, node: CasNode) => {
const preview = JSON.stringify(node.payload).slice(0, 100);
entries.push({
hash,
type: node.type,
timestamp: node.timestamp,
payloadPreview: preview,
});
});
return entries;
}
export function formatNodeWalk(rootHash: string, entries: WalkEntry[]): string {
const lines: string[] = [];
lines.push(`walk from: ${rootHash}`);
lines.push(`nodes: ${entries.length}`);
for (const entry of entries) {
lines.push("");
lines.push(` hash: ${entry.hash}`);
lines.push(` type: ${entry.type}`);
lines.push(` time: ${new Date(entry.timestamp).toISOString()}`);
lines.push(` data: ${entry.payloadPreview}`);
}
return lines.join("\n");
}
@@ -1,23 +0,0 @@
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { bootstrap } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
export function getJsonCasDir(storageRoot: string): string {
return join(storageRoot, "json-cas");
}
export type OpenStoreResult = {
store: Store;
typeHashes: WorkflowSchemaHashes;
};
export async function openStore(storageRoot: string): Promise<OpenStoreResult> {
const dir = getJsonCasDir(storageRoot);
const store = createFsStore(dir);
await bootstrap(store);
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
@@ -1,116 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import { openStore } from "./store.js";
type StepEntry = {
hash: Hash;
payload: ThreadStepPayload;
};
function collectStepsInOrder(
store: Store,
typeHashes: WorkflowSchemaHashes,
startHash: Hash,
): StepEntry[] {
const stepMap = new Map<Hash, StepEntry>();
for (const hash of store.list()) {
const node = store.get(hash);
if (node === null || node.type !== typeHashes.threadStep) continue;
const p = node.payload as ThreadStepPayload;
if (p.start === startHash) {
stepMap.set(hash, { hash, payload: p });
}
}
if (stepMap.size === 0) return [];
// Find the terminal step: the one whose hash is not referenced as `previous` by any other step
const previousSet = new Set<Hash>();
for (const entry of stepMap.values()) {
if (entry.payload.previous !== null) {
previousSet.add(entry.payload.previous);
}
}
const terminalEntries = [...stepMap.values()].filter((e) => !previousSet.has(e.hash));
if (terminalEntries.length === 0) return [...stepMap.values()];
// Walk backward from terminal to build ordered list
const ordered: StepEntry[] = [];
let current: StepEntry | undefined = terminalEntries[0];
while (current !== undefined) {
ordered.unshift(current);
const prevHash = current.payload.previous;
if (prevHash === null) break;
current = stepMap.get(prevHash);
}
return ordered;
}
function getContentText(store: Store, contentHash: Hash): string {
const node = store.get(contentHash);
if (node === null) return "(not found)";
const payload = node.payload as ContentPayload;
return typeof payload.text === "string" ? payload.text : "(no text)";
}
export type ThreadStep = {
hash: Hash;
role: string;
meta: Record<string, unknown>;
contentPreview: string;
};
export type ThreadShowResult = {
startHash: Hash;
steps: ThreadStep[];
};
export async function cmdThreadShow(
storageRoot: string,
startHash: string,
): Promise<ThreadShowResult | null> {
const { store, typeHashes } = await openStore(storageRoot);
const startNode = store.get(startHash);
if (startNode === null) return null;
const entries = collectStepsInOrder(store, typeHashes, startHash);
const steps: ThreadStep[] = entries.map((entry) => {
const rawText = getContentText(store, entry.payload.content);
const preview = rawText.slice(0, 120).replace(/\n/g, " ");
return {
hash: entry.hash,
role: entry.payload.role,
meta: entry.payload.meta,
contentPreview: preview,
};
});
return { startHash, steps };
}
export function formatThreadShow(result: ThreadShowResult): string {
const lines: string[] = [];
lines.push(`thread: ${result.startHash}`);
lines.push(`steps: ${result.steps.length}`);
for (let i = 0; i < result.steps.length; i++) {
const step = result.steps[i];
lines.push("");
lines.push(` [${i + 1}] ${step.role} (${step.hash})`);
if (Object.keys(step.meta).length > 0) {
lines.push(` meta: ${JSON.stringify(step.meta)}`);
}
lines.push(` > ${step.contentPreview}`);
}
return lines.join("\n");
}
@@ -1,5 +0,0 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type JsonCasDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -1,21 +0,0 @@
import { readFileSync } from "node:fs";
import type { WorkflowInput } from "@uncaged/workflow-json-def";
import { registerWorkflow } from "@uncaged/workflow-json-def";
import { openStore } from "./store.js";
export type WorkflowRegisterResult = {
hash: string;
};
export async function cmdWorkflowRegister(
storageRoot: string,
filePath: string,
): Promise<WorkflowRegisterResult> {
const raw = readFileSync(filePath, "utf-8");
const workflowDef = JSON.parse(raw) as WorkflowInput;
const { store, typeHashes } = await openStore(storageRoot);
const hash = await registerWorkflow(store, typeHashes, workflowDef);
return { hash };
}
@@ -1,38 +0,0 @@
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
import { loadWorkflow } from "@uncaged/workflow-json-def";
import { openStore } from "./store.js";
export async function cmdWorkflowShow(
storageRoot: string,
hash: string,
): Promise<HydratedWorkflow | null> {
const { store, typeHashes } = await openStore(storageRoot);
return loadWorkflow(store, typeHashes, hash);
}
export function formatWorkflowShow(hash: string, wf: HydratedWorkflow): string {
const lines: string[] = [];
lines.push(`workflow: ${wf.name}`);
lines.push(`hash: ${hash}`);
lines.push(`desc: ${wf.description}`);
lines.push("");
lines.push("roles:");
for (const [name, role] of Object.entries(wf.roles)) {
lines.push(` ${name}:`);
lines.push(` description: ${role.description}`);
lines.push(` systemPrompt: ${role.systemPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
lines.push(` extractPrompt: ${role.extractPrompt.slice(0, 80).replace(/\n/g, " ")}...`);
}
if (wf.moderator.length > 0) {
lines.push("");
lines.push("moderator:");
for (const rule of wf.moderator) {
const when = rule.when === null ? "(always)" : `when: ${rule.when}`;
lines.push(` ${rule.from}${rule.to} [${when}]`);
}
}
return lines.join("\n");
}
+2 -1
View File
@@ -249,7 +249,8 @@ Each role has:
|-------|------|---------|
| \`description\` | string | What the role does |
| \`systemPrompt\` | string | System prompt for the agent |
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
## Development Workflow
@@ -1,40 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command' and 'timeout'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
expect(required).toContain("timeout");
});
test("configSchema properties include command, model, timeout, workspace", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
expect(props).toHaveProperty("workspace");
});
});
@@ -11,9 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
function throwCursorSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -1,34 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-cursor.
* Config maps to {@link CursorAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-cursor",
version: "0.5.0-alpha.4",
capabilities: ["cursor-cli", "workspace-agent"],
configSchema: {
type: "object",
required: ["command", "timeout"],
properties: {
command: {
type: "string",
description: "Absolute path to the cursor-agent CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to cursor-agent --model; null means auto.",
},
timeout: {
type: "number",
description: "Timeout in milliseconds; 0 means no limit.",
},
workspace: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Override workspace path; null resolves from thread context.",
},
},
additionalProperties: false,
},
};
@@ -1,38 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
});
test("configSchema properties include command, model, timeout", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
});
});
@@ -13,9 +13,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export { packageDescriptor } from "./package-descriptor.js";
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
function throwHermesSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
@@ -1,30 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-hermes.
* Config maps to {@link HermesAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-hermes",
version: "0.5.0-alpha.4",
capabilities: ["hermes-cli", "yolo-mode"],
configSchema: {
type: "object",
required: ["command"],
properties: {
command: {
type: "string",
description: "Absolute path to the hermes CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to hermes --model; null uses the CLI default.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
description: "Timeout in milliseconds; null means no limit.",
},
},
additionalProperties: false,
},
};
@@ -1,40 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires baseUrl, apiKey, model", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("baseUrl");
expect(required).toContain("apiKey");
expect(required).toContain("model");
});
test("configSchema properties include baseUrl, apiKey, model", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("baseUrl");
expect(props).toHaveProperty("apiKey");
expect(props).toHaveProperty("model");
});
});
-1
View File
@@ -4,4 +4,3 @@ export {
type LlmChatError,
type LlmMessage,
} from "./create-llm-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
@@ -1,30 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-llm.
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-llm",
version: "0.5.0-alpha.4",
capabilities: ["llm-single-turn"],
configSchema: {
type: "object",
required: ["baseUrl", "apiKey", "model"],
properties: {
baseUrl: {
type: "string",
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
},
apiKey: {
type: "string",
description: "API key for the provider.",
},
model: {
type: "string",
description: "Model identifier passed as the `model` field in the request body.",
},
},
additionalProperties: false,
},
};
@@ -1,36 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires maxRounds", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("maxRounds");
});
test("configSchema properties include maxRounds", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("maxRounds");
});
});
@@ -1,5 +1,4 @@
export { createReactAdapter } from "./create-react-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -1,25 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-react.
*
* Config represents the serializable subset of {@link ReactAdapterConfig}.
* The `llm` function and `toolHandler` are runtime constructs and are not
* stored in the CAS agent node; only `maxRounds` is serializable.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-react",
version: "0.5.0-alpha.4",
capabilities: ["react-loop", "tool-calling"],
configSchema: {
type: "object",
required: ["maxRounds"],
properties: {
maxRounds: {
type: "number",
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
},
},
additionalProperties: false,
},
};
@@ -53,35 +53,6 @@ function computeNodeStates(records: readonly ThreadRecord[]): Map<string, NodeSt
return states;
}
function isClickableGraphNode(nodeStates: Map<string, NodeState>, nodeId: string): boolean {
const state = nodeStates.get(nodeId);
return state !== undefined && state !== "default";
}
function scrollToFirstRecord(): void {
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
}
function scrollToRoleOccurrence(
nodeId: string,
indicesByRole: Map<string, number[]>,
clickCycleRef: { current: Map<string, number> },
onHighlight: (role: string) => void,
): void {
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el === null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
onHighlight(nodeId);
}
export function ThreadDetail({ client, threadId, onBack }: Props) {
const sse = useSSE(client, threadId);
const { status, data, error } = useFetch(() => getThread(client, threadId), [client, threadId]);
@@ -125,29 +96,44 @@ export function ThreadDetail({ client, threadId, onBack }: Props) {
// Track which occurrence to jump to next per role (cycling)
const clickCycleRef = useRef<Map<string, number>>(new Map());
const highlightRole = useCallback((role: string) => {
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(role);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}, []);
const handleGraphNodeClick = useCallback(
(nodeId: string) => {
if (!isClickableGraphNode(nodeStates, nodeId)) return;
// Only allow clicks on lit (non-default) nodes
if (nodeStates.get(nodeId) === undefined || nodeStates.get(nodeId) === "default") return;
// __start__: scroll to the first record (thread-start prompt)
if (nodeId === "__start__") {
scrollToFirstRecord();
const firstCard = document.querySelector('[data-record-index="0"]');
if (firstCard !== null) firstCard.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// __end__: scroll to bottom
if (nodeId === "__end__") {
recordsEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
return;
}
scrollToRoleOccurrence(nodeId, indicesByRole, clickCycleRef, highlightRole);
// Role nodes: cycle through occurrences
const indices = indicesByRole.get(nodeId);
if (indices === undefined || indices.length === 0) return;
const cycle = clickCycleRef.current.get(nodeId) ?? 0;
const idx = indices[cycle % indices.length];
clickCycleRef.current.set(nodeId, cycle + 1);
const el = document.querySelector(`[data-record-index="${idx}"]`);
if (el !== null) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
},
[nodeStates, indicesByRole, highlightRole],
[nodeStates, indicesByRole],
);
useEffect(() => {
@@ -39,119 +39,67 @@ function resolveType(prop: Record<string, unknown>): string {
return String(prop.type ?? "unknown");
}
function variantLabel(
variantProps: Record<string, Record<string, unknown>>,
variantIndex: number,
): string {
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) return `${pName}: ${String(pDef.const)}`;
}
return `Variant ${variantIndex + 1}`;
}
function childPrefixForDepth(depth: number, parentPrefix: string): string {
return depth > 0 ? `${parentPrefix} ` : " ";
}
function flattenOneOfVariants(
oneOf: Array<Record<string, unknown>>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel(variantProps, vi)}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const variantChildPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
rows.push(
...flattenProperty(
pName,
pDef,
depth + 1,
variantChildPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
),
);
}
}
return rows;
}
function flattenSchemaProperties(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
const rows: SchemaRow[] = [];
for (const [name, prop] of Object.entries(props)) {
rows.push(...flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required));
}
return rows;
}
function flattenSchema(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
// Handle oneOf / discriminatedUnion
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
if (Array.isArray(oneOf) && oneOf.length > 0) {
return flattenOneOfVariants(oneOf, depth, parentPrefix, keyPrefix);
}
return flattenSchemaProperties(schema, depth, parentPrefix, keyPrefix);
}
function flattenNestedPropertyRows(
name: string,
prop: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
hasOneOf: boolean,
): SchemaRow[] {
const childPrefix = childPrefixForDepth(depth, parentPrefix);
const nestedKeyPrefix = `${keyPrefix}${name}-`;
if (prop.type === "object" && prop.properties !== undefined) {
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
}
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
return flattenSchema(items, depth + 1, childPrefix, nestedKeyPrefix);
for (let vi = 0; vi < oneOf.length; vi++) {
const variant = oneOf[vi];
const variantProps = (variant.properties ?? {}) as Record<string, Record<string, unknown>>;
let variantLabel = `Variant ${vi + 1}`;
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) {
variantLabel = `${pName}: ${String(pDef.const)}`;
break;
}
}
const isLast = vi === oneOf.length - 1;
const connector = isLast ? "└" : "├";
rows.push({
key: `${keyPrefix}variant-${vi}`,
name: `${parentPrefix}${connector} ${variantLabel}`,
type: "",
description: "",
depth,
prefix: parentPrefix,
isVariantHeader: true,
});
const childPrefix = `${parentPrefix}${isLast ? " " : "│ "}`;
const variantRequired = new Set<string>(
Array.isArray(variant.required) ? (variant.required as string[]) : [],
);
for (const [pName, pDef] of Object.entries(variantProps)) {
if (pDef.const !== undefined) continue;
const subRows = flattenProperty(
pName,
pDef,
depth + 1,
childPrefix,
`${keyPrefix}v${vi}-`,
variantRequired,
);
rows.push(...subRows);
}
}
return rows;
}
if (hasOneOf) {
return flattenSchema(prop as Record<string, unknown>, depth + 1, childPrefix, nestedKeyPrefix);
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>(
Array.isArray(schema.required) ? (schema.required as string[]) : [],
);
for (const [name, prop] of Object.entries(props)) {
const subRows = flattenProperty(name, prop, depth, parentPrefix, keyPrefix, required);
rows.push(...subRows);
}
return [];
return rows;
}
function flattenProperty(
@@ -162,23 +110,55 @@ function flattenProperty(
keyPrefix: string,
required: Set<string>,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const hasOneOf = Array.isArray(prop.oneOf) && (prop.oneOf as unknown[]).length > 0;
let type = hasOneOf ? "⊕ oneOf" : resolveType(prop);
if (!required.has(name)) type += "?";
const description = String(prop.description ?? "");
const displayName = depth > 0 ? `${parentPrefix}└─ ${name}` : name;
const rows: SchemaRow[] = [
{
key: `${keyPrefix}${name}`,
name: depth > 0 ? `${parentPrefix}└─ ${name}` : name,
type,
description: String(prop.description ?? ""),
depth,
prefix: parentPrefix,
isVariantHeader: false,
},
];
rows.push({
key: `${keyPrefix}${name}`,
name: displayName,
type,
description,
depth,
prefix: parentPrefix,
isVariantHeader: false,
});
if (prop.type === "object" && prop.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined && items.type === "object" && items.properties !== undefined) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(...flattenSchema(items, depth + 1, childPrefix, `${keyPrefix}${name}-`));
}
}
if (hasOneOf) {
const childPrefix = depth > 0 ? `${parentPrefix} ` : " ";
rows.push(
...flattenSchema(
prop as Record<string, unknown>,
depth + 1,
childPrefix,
`${keyPrefix}${name}-`,
),
);
}
rows.push(...flattenNestedPropertyRows(name, prop, depth, parentPrefix, keyPrefix, hasOneOf));
return rows;
}
@@ -36,128 +36,6 @@ function edgeKey(e: WorkflowGraphEdge): string {
return `${e.from}->${e.to}::${e.condition}`;
}
function collectNodeIds(edges: readonly WorkflowGraphEdge[]): Set<string> {
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
return ids;
}
function detectBackEdges(ids: Set<string>, edges: readonly WorkflowGraphEdge[]): Set<string> {
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const backEdges = new Set<string>();
const color = new Map<string, number>();
for (const id of ids) color.set(id, WHITE);
const fullAdj = new Map<string, string[]>();
for (const id of ids) fullAdj.set(id, []);
for (const e of edges) {
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
}
function dfs(u: string): void {
color.set(u, GRAY);
for (const v of fullAdj.get(u) ?? []) {
const c = color.get(v) ?? WHITE;
if (c === GRAY) {
backEdges.add(`${u}->${v}`);
} else if (c === WHITE) {
dfs(v);
}
}
color.set(u, BLACK);
}
if (ids.has(START_ID)) dfs(START_ID);
for (const id of ids) {
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
}
return backEdges;
}
function buildDagAdjacency(
ids: Set<string>,
edges: readonly WorkflowGraphEdge[],
backEdges: Set<string>,
): Map<string, string[]> {
const adj = new Map<string, string[]>();
for (const id of ids) adj.set(id, []);
for (const e of edges) {
if (e.from === e.to) continue;
if (backEdges.has(`${e.from}->${e.to}`)) continue;
adj.get(e.from)?.push(e.to);
}
return adj;
}
function computeInDegrees(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
const inDegree = new Map<string, number>();
for (const id of ids) inDegree.set(id, 0);
for (const id of ids) {
for (const next of adj.get(id) ?? []) {
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
}
}
return inDegree;
}
function relaxLongestPathNeighbors(
cur: string,
curRank: number,
adj: Map<string, string[]>,
rank: Map<string, number>,
inDegree: Map<string, number>,
queue: string[],
): void {
for (const next of adj.get(cur) ?? []) {
const prevRank = rank.get(next) ?? 0;
if (curRank + 1 > prevRank) rank.set(next, curRank + 1);
const deg = (inDegree.get(next) ?? 1) - 1;
inDegree.set(next, deg);
if (deg === 0) queue.push(next);
}
}
function longestPathRanks(ids: Set<string>, adj: Map<string, string[]>): Map<string, number> {
const inDegree = computeInDegrees(ids, adj);
const rank = new Map<string, number>();
const queue: string[] = [];
for (const id of ids) {
if ((inDegree.get(id) ?? 0) === 0) {
queue.push(id);
rank.set(id, 0);
}
}
while (queue.length > 0) {
const cur = queue.shift();
if (cur === undefined) break;
relaxLongestPathNeighbors(cur, rank.get(cur) ?? 0, adj, rank, inDegree, queue);
}
return rank;
}
function compareLayerNodes(a: string, b: string): number {
if (a === START_ID) return -1;
if (b === START_ID) return 1;
if (a === END_ID) return 1;
if (b === END_ID) return -1;
return a.localeCompare(b);
}
function ranksToLayers(rank: Map<string, number>): string[][] {
const maxRank = Math.max(...[...rank.values()], 0);
const layers: string[][] = [];
for (let r = 0; r <= maxRank; r++) layers.push([]);
for (const [id, r] of rank) layers[r].push(id);
for (const layer of layers) layer.sort(compareLayerNodes);
return layers.filter((l) => l.length > 0);
}
// ── Strategy 1: Longest-path layering (Sugiyama step 1) ─────────────
/**
@@ -171,11 +49,123 @@ function ranksToLayers(rank: Map<string, number>): string[][] {
* on the resulting DAG, then the removed edges become feedback edges.
*/
function computeLayersLongestPath(edges: readonly WorkflowGraphEdge[]): string[][] {
const ids = collectNodeIds(edges);
const backEdges = detectBackEdges(ids, edges);
const adj = buildDagAdjacency(ids, edges, backEdges);
const rank = longestPathRanks(ids, adj);
return ranksToLayers(rank);
// Collect all node IDs
const ids = new Set<string>();
for (const e of edges) {
ids.add(e.from);
ids.add(e.to);
}
// Build adjacency (excluding self-loops)
const adj = new Map<string, string[]>();
const inEdges = new Map<string, string[]>();
for (const id of ids) {
adj.set(id, []);
inEdges.set(id, []);
}
// Detect back-edges via DFS to break cycles
const backEdges = new Set<string>();
{
const WHITE = 0;
const GRAY = 1;
const BLACK = 2;
const color = new Map<string, number>();
for (const id of ids) color.set(id, WHITE);
// Temporary full adjacency for cycle detection
const fullAdj = new Map<string, string[]>();
for (const id of ids) fullAdj.set(id, []);
for (const e of edges) {
if (e.from !== e.to) fullAdj.get(e.from)?.push(e.to);
}
function dfs(u: string): void {
color.set(u, GRAY);
for (const v of fullAdj.get(u) ?? []) {
const c = color.get(v) ?? WHITE;
if (c === GRAY) {
// Back-edge: u -> v where v is an ancestor
backEdges.add(`${u}->${v}`);
} else if (c === WHITE) {
dfs(v);
}
}
color.set(u, BLACK);
}
// Start DFS from __start__ first for determinism
if (ids.has(START_ID)) dfs(START_ID);
for (const id of ids) {
if ((color.get(id) ?? WHITE) === WHITE) dfs(id);
}
}
// Build DAG adjacency (without back-edges)
for (const e of edges) {
if (e.from === e.to) continue;
if (backEdges.has(`${e.from}->${e.to}`)) continue;
adj.get(e.from)?.push(e.to);
inEdges.get(e.to)?.push(e.from);
}
// Longest-path ranking via topological order (Kahn's algorithm)
const inDegree = new Map<string, number>();
for (const id of ids) inDegree.set(id, 0);
for (const id of ids) {
for (const next of adj.get(id) ?? []) {
inDegree.set(next, (inDegree.get(next) ?? 0) + 1);
}
}
const rank = new Map<string, number>();
const queue: string[] = [];
for (const id of ids) {
if ((inDegree.get(id) ?? 0) === 0) {
queue.push(id);
rank.set(id, 0);
}
}
while (queue.length > 0) {
const cur = queue.shift()!;
const curRank = rank.get(cur) ?? 0;
for (const next of adj.get(cur) ?? []) {
// Longest path: take max
const prevRank = rank.get(next) ?? 0;
if (curRank + 1 > prevRank) {
rank.set(next, curRank + 1);
}
const deg = (inDegree.get(next) ?? 1) - 1;
inDegree.set(next, deg);
if (deg === 0) {
queue.push(next);
}
}
}
// Group by rank
const maxRank = Math.max(...[...rank.values()], 0);
const layers: string[][] = [];
for (let r = 0; r <= maxRank; r++) {
layers.push([]);
}
for (const [id, r] of rank) {
layers[r].push(id);
}
// Sort within layers alphabetically for stability, but __start__ first, __end__ last
for (const layer of layers) {
layer.sort((a, b) => {
if (a === START_ID) return -1;
if (b === START_ID) return 1;
if (a === END_ID) return 1;
if (b === END_ID) return -1;
return a.localeCompare(b);
});
}
// Remove empty layers
return layers.filter((l) => l.length > 0);
}
// ── Shared helpers ──────────────────────────────────────────────────
@@ -211,164 +201,132 @@ function buildTerminalNode(
};
}
type EdgeLayoutContext = {
rank: Map<string, number>;
nodePositions: Map<string, { x: number; y: number; w: number; h: number }>;
centerX: number;
routedCountByTarget: Map<string, number>;
};
// ── Longest-path layout (uses same edge-building as before) ─────────
function computeEdgeLabelPosition(
e: WorkflowGraphEdge,
ctx: EdgeLayoutContext,
isFeedback: boolean,
isSkipForward: boolean,
isSelfLoop: boolean,
): { labelX: number | null; labelY: number | null; feedbackSide: "right" | "left" | null } {
const sourcePos = ctx.nodePositions.get(e.from);
const targetPos = ctx.nodePositions.get(e.to);
if (sourcePos === undefined || targetPos === undefined) {
return { labelX: null, labelY: null, feedbackSide: null };
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: layout logic is inherently branchy
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
const layers = computeLayersLongestPath(input.edges);
if (isFeedback || isSkipForward) {
const count = ctx.routedCountByTarget.get(e.to) ?? 0;
ctx.routedCountByTarget.set(e.to, count + 1);
const feedbackSide = count % 2 === 0 ? "right" : "left";
const offsetX =
feedbackSide === "right"
? ctx.centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
: ctx.centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
return { labelX: offsetX, labelY: midY, feedbackSide };
}
if (isSelfLoop) {
return { labelX: null, labelY: null, feedbackSide: null };
}
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
return { labelX: ctx.centerX, labelY: midY, feedbackSide: null };
}
function buildConditionEdge(e: WorkflowGraphEdge, ctx: EdgeLayoutContext): Edge {
const isFallback = e.condition === "FALLBACK";
const isSelfLoop = e.from === e.to;
const sourceRank = ctx.rank.get(e.from) ?? 0;
const targetRank = ctx.rank.get(e.to) ?? 0;
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
const routed = isFeedback || isSkipForward;
const { labelX, labelY, feedbackSide } = computeEdgeLabelPosition(
e,
ctx,
isFeedback,
isSkipForward,
isSelfLoop,
);
return {
id: edgeKey(e),
source: e.from,
target: e.to,
sourceHandle: routed ? (feedbackSide === "left" ? "left-out" : "right-out") : "bottom-out",
targetHandle: routed ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
isFeedback: routed,
isSelfLoop,
feedbackSide,
labelX,
labelY,
},
};
}
const LAYER_H_GAP = 40;
type NodePosition = { x: number; y: number; w: number; h: number };
function layerIndexRank(layers: string[][]): Map<string, number> {
// Flatten layers into a rank map (layer index = rank)
const rank = new Map<string, number>();
for (let i = 0; i < layers.length; i++) {
for (const id of layers[i]) rank.set(id, i);
for (const id of layers[i]) {
rank.set(id, i);
}
}
return rank;
}
function computeLayerWidths(layers: string[][], hGap: number): number[] {
return layers.map((layer) => {
// Horizontal gap between nodes in the same layer
const H_GAP = 40;
// Position nodes: each layer is a horizontal row
const nodePositions = new Map<string, { x: number; y: number; w: number; h: number }>();
// Find max layer width for centering
const layerWidths: number[] = [];
for (const layer of layers) {
let w = 0;
for (const id of layer) w += nodeSize(id).width;
return w + (layer.length - 1) * hGap;
});
}
for (const id of layer) {
w += nodeSize(id).width;
}
w += (layer.length - 1) * H_GAP;
layerWidths.push(w);
}
const maxLayerWidth = Math.max(...layerWidths, ROLE_NODE_WIDTH);
const centerX = maxLayerWidth / 2;
function layoutNodePositions(
layers: string[][],
layerWidths: number[],
centerX: number,
hGap: number,
): Map<string, NodePosition> {
const nodePositions = new Map<string, NodePosition>();
let y = 0;
for (let li = 0; li < layers.length; li++) {
const layer = layers[li];
let x = centerX - layerWidths[li] / 2;
const totalWidth = layerWidths[li];
let x = centerX - totalWidth / 2;
let maxH = 0;
for (const id of layer) {
const size = nodeSize(id);
nodePositions.set(id, { x, y, w: size.width, h: size.height });
x += size.width + hGap;
x += size.width + H_GAP;
if (size.height > maxH) maxH = size.height;
}
y += maxH + LAYER_GAP;
}
return nodePositions;
}
function buildLayoutNodes(
layers: string[][],
nodePositions: Map<string, NodePosition>,
input: LayoutInput,
): Node[] {
// Build nodes
const nodes: Node[] = [];
for (const layer of layers) {
for (const id of layer) {
const pos = nodePositions.get(id);
if (pos === undefined) continue;
const state = input.nodeStates.get(id) ?? "default";
const xy = { x: pos.x, y: pos.y };
if (id === START_ID || id === END_ID) {
nodes.push(buildTerminalNode(id, xy, state));
nodes.push(buildTerminalNode(id, { x: pos.x, y: pos.y }, state));
} else {
nodes.push(buildRoleNode(id, xy, input.roles, state));
nodes.push(buildRoleNode(id, { x: pos.x, y: pos.y }, input.roles, state));
}
}
}
return nodes;
}
// ── Longest-path layout (uses same edge-building as before) ─────────
// Build edges with label positions
const routedCountByTarget = new Map<string, number>();
const edges: Edge[] = input.edges.map((e) => {
const isFallback = e.condition === "FALLBACK";
const isSelfLoop = e.from === e.to;
const sourceRank = rank.get(e.from) ?? 0;
const targetRank = rank.get(e.to) ?? 0;
const isFeedback = !isSelfLoop && targetRank <= sourceRank;
const isSkipForward = !isSelfLoop && !isFeedback && targetRank - sourceRank > 1;
const sourcePos = nodePositions.get(e.from);
const targetPos = nodePositions.get(e.to);
let labelX: number | null = null;
let labelY: number | null = null;
let feedbackSide: "right" | "left" | null = null;
if (sourcePos !== undefined && targetPos !== undefined) {
if (isFeedback || isSkipForward) {
const count = routedCountByTarget.get(e.to) ?? 0;
routedCountByTarget.set(e.to, count + 1);
feedbackSide = count % 2 === 0 ? "right" : "left";
const offsetX =
feedbackSide === "right"
? centerX + ROLE_NODE_WIDTH / 2 + FEEDBACK_OFFSET_X
: centerX - ROLE_NODE_WIDTH / 2 - FEEDBACK_OFFSET_X;
const midY = (sourcePos.y + sourcePos.h / 2 + targetPos.y + targetPos.h / 2) / 2;
labelX = offsetX;
labelY = midY;
} else if (!isSelfLoop) {
const midX = centerX;
const midY = (sourcePos.y + sourcePos.h + targetPos.y) / 2;
labelX = midX;
labelY = midY;
}
}
return {
id: edgeKey(e),
source: e.from,
target: e.to,
sourceHandle:
isFeedback || isSkipForward
? feedbackSide === "left"
? "left-out"
: "right-out"
: "bottom-out",
targetHandle:
isFeedback || isSkipForward ? (feedbackSide === "left" ? "left-in" : "right-in") : "top-in",
type: "condition",
data: {
condition: e.condition,
conditionDescription: e.conditionDescription,
isFallback,
isFeedback: isFeedback || isSkipForward,
isSelfLoop,
feedbackSide,
labelX,
labelY,
},
};
});
function computeLayoutLongestPath(input: LayoutInput): LayoutResult {
const layers = computeLayersLongestPath(input.edges);
const rank = layerIndexRank(layers);
const layerWidths = computeLayerWidths(layers, LAYER_H_GAP);
const centerX = Math.max(...layerWidths, ROLE_NODE_WIDTH) / 2;
const nodePositions = layoutNodePositions(layers, layerWidths, centerX, LAYER_H_GAP);
const nodes = buildLayoutNodes(layers, nodePositions, input);
const edgeCtx: EdgeLayoutContext = {
rank,
nodePositions,
centerX,
routedCountByTarget: new Map<string, number>(),
};
const edges: Edge[] = input.edges.map((e) => buildConditionEdge(e, edgeCtx));
return { nodes, edges };
}
@@ -1,868 +0,0 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, type Store, walk } from "@uncaged/json-cas";
import {
type ContentPayload,
registerWorkflowSchemas,
type ThreadEndPayload,
type ThreadStartPayload,
type ThreadStepPayload,
type WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import { registerWorkflow, type WorkflowInput } from "@uncaged/workflow-json-def";
import {
buildJsonCasThreadContext,
buildJsonCasThreadSnapshot,
readContentText,
} from "../src/engine/json-cas-context.js";
import { executeJsonCasThread } from "../src/engine/json-cas-engine.js";
import type {
JsonCasAgentFn,
JsonCasEngineIo,
JsonCasEngineOptions,
} from "../src/engine/json-cas-types.js";
// ── Test fixtures ─────────────────────────────────────────────────────
const START = "__start__";
const END = "__end__";
const SIMPLE_WORKFLOW: WorkflowInput = {
name: "test-simple",
description: "A simple two-role workflow for testing",
roles: {
planner: {
description: "Plans the work",
systemPrompt: "You are a planner.",
extractPrompt: "Extract planner output.",
schema: {
type: "object",
required: ["plan"],
properties: { plan: { type: "string" } },
},
},
coder: {
description: "Implements the plan",
systemPrompt: "You are a coder.",
extractPrompt: "Extract coder output.",
schema: {
type: "object",
required: ["code"],
properties: { code: { type: "string" } },
},
},
},
moderator: [
{ from: START, to: "planner", when: null },
{ from: "planner", to: "coder", when: null },
{ from: "coder", to: END, when: null },
],
};
const SINGLE_ROLE_WORKFLOW: WorkflowInput = {
name: "test-single",
description: "A single-role workflow",
roles: {
worker: {
description: "Does all the work",
systemPrompt: "You are a worker.",
extractPrompt: "Extract worker output.",
schema: {
type: "object",
required: ["result"],
properties: { result: { type: "string" } },
},
},
},
moderator: [
{ from: START, to: "worker", when: null },
{ from: "worker", to: END, when: null },
],
};
const CONDITIONAL_WORKFLOW: WorkflowInput = {
name: "test-conditional",
description: "A workflow with JSONata conditions",
roles: {
checker: {
description: "Checks the input",
systemPrompt: "You are a checker.",
extractPrompt: "Extract checker output.",
schema: {
type: "object",
required: ["status"],
properties: { status: { type: "string" } },
},
},
fixer: {
description: "Fixes issues",
systemPrompt: "You are a fixer.",
extractPrompt: "Extract fixer output.",
schema: {
type: "object",
required: ["fix"],
properties: { fix: { type: "string" } },
},
},
},
moderator: [
{ from: START, to: "checker", when: null },
{ from: "checker", to: END, when: "steps[-1].meta.status = 'ok'" },
{ from: "checker", to: "fixer", when: null },
{ from: "fixer", to: "checker", when: null },
],
};
function noLogger(): (tag: string, content: string) => void {
return () => {};
}
async function setupStore(): Promise<{
store: Store;
typeHashes: WorkflowSchemaHashes;
}> {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
async function setupWorkflow(
store: Store,
typeHashes: WorkflowSchemaHashes,
workflowDef: WorkflowInput,
) {
const workflowHash = await registerWorkflow(store, typeHashes, workflowDef);
return { workflowHash };
}
function makeOptions(overrides: Partial<JsonCasEngineOptions> = {}): JsonCasEngineOptions {
return {
depth: 0,
parentThread: null,
signal: new AbortController().signal,
agents: {},
...overrides,
};
}
function makeIo(store: Store, typeHashes: WorkflowSchemaHashes, threadId: string): JsonCasEngineIo {
return { threadId, store, typeHashes };
}
/**
* A mock agent that returns a canned text and meta for each role.
*/
function createMockAgent(
responses: Record<string, { text: string; meta: Record<string, unknown> }>,
): JsonCasAgentFn {
return async (role, _systemPrompt, _snapshot) => {
const resp = responses[role];
if (resp === undefined) {
throw new Error(`mock agent: no response configured for role "${role}"`);
}
return { ...resp, react: null };
};
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("executeJsonCasThread", () => {
describe("thread lifecycle", () => {
test("simple two-role workflow creates start, two steps, and end nodes", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "I will plan", meta: { plan: "phase-1" } },
coder: { text: "I wrote code", meta: { code: "done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "Build a widget",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD01"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
expect(result.summary).toContain("END");
expect(result.rootHash).toBeTruthy();
const endNode = store.get(result.rootHash);
expect(endNode).not.toBeNull();
const endPayload = endNode!.payload as ThreadEndPayload;
expect(endPayload.returnCode).toBe(0);
expect(endPayload.start).toBeTruthy();
expect(endPayload.lastStep).toBeTruthy();
});
test("single-role workflow creates correct chain", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "work done", meta: { result: "success" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "Do the thing",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD02"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
const endNode = store.get(result.rootHash);
expect(endNode).not.toBeNull();
const endPayload = endNode!.payload as ThreadEndPayload;
const lastStepNode = store.get(endPayload.lastStep);
expect(lastStepNode).not.toBeNull();
const lastStepPayload = lastStepNode!.payload as ThreadStepPayload;
expect(lastStepPayload.role).toBe("worker");
expect(lastStepPayload.previous).toBeNull();
const startNode = store.get(endPayload.start);
expect(startNode).not.toBeNull();
const startPayload = startNode!.payload as ThreadStartPayload;
expect(startPayload.input).toBe("Do the thing");
expect(startPayload.depth).toBe(0);
});
});
describe("CAS node structure", () => {
test("thread-start contains workflow ref, input, depth, agents", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "ok", meta: { result: "ok" } },
});
const agentHash = await store.put(typeHashes.agent, {
package: "test-agent",
version: "1.0.0",
config: {},
});
const result = await executeJsonCasThread({
workflowHash,
input: "Test input",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD03"),
options: makeOptions({ agents: { worker: agentHash }, depth: 2 }),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
expect(startPayload.workflow).toBe(workflowHash);
expect(startPayload.input).toBe("Test input");
expect(startPayload.depth).toBe(2);
expect(startPayload.parentThread).toBeNull();
expect(startPayload.agents).toEqual({ worker: agentHash });
});
test("thread-start records parentThread when provided", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "nested", meta: { result: "nested" } },
});
const fakeParent = "FAKEPARENT0001";
const result = await executeJsonCasThread({
workflowHash,
input: "nested task",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD04"),
options: makeOptions({ parentThread: fakeParent, depth: 1 }),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startPayload = store.get(endPayload.start)!.payload as ThreadStartPayload;
expect(startPayload.parentThread).toBe(fakeParent);
expect(startPayload.depth).toBe(1);
});
test("each thread-step has content, react, start, and previous refs", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan text", meta: { plan: "p1" } },
coder: { text: "code text", meta: { code: "c1" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "go",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD05"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startHash = endPayload.start;
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
expect(step2.role).toBe("coder");
expect(step2.start).toBe(startHash);
expect(step2.previous).not.toBeNull();
const contentNode2 = store.get(step2.content);
expect(contentNode2).not.toBeNull();
expect((contentNode2!.payload as ContentPayload).text).toBe("code text");
const reactNode2 = store.get(step2.react);
expect(reactNode2).not.toBeNull();
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
expect(step1.role).toBe("planner");
expect(step1.start).toBe(startHash);
expect(step1.previous).toBeNull();
const contentNode1 = store.get(step1.content);
expect(contentNode1).not.toBeNull();
expect((contentNode1!.payload as ContentPayload).text).toBe("plan text");
});
test("thread-end references start and last step", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan", meta: { plan: "x" } },
coder: { text: "code", meta: { code: "x" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "test",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD06"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
expect(endPayload.returnCode).toBe(0);
expect(endPayload.summary).toBeTruthy();
const startNode = store.get(endPayload.start);
expect(startNode).not.toBeNull();
expect((startNode!.payload as ThreadStartPayload).workflow).toBe(workflowHash);
const lastStepNode = store.get(endPayload.lastStep);
expect(lastStepNode).not.toBeNull();
expect((lastStepNode!.payload as ThreadStepPayload).role).toBe("coder");
});
test("content nodes store the agent text verbatim", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const longText = "This is a longer text with\nnewlines\nand special chars: <>&\"'";
const agentFn = createMockAgent({
worker: { text: longText, meta: { result: "done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "process this",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD07"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
const contentPayload = store.get(stepPayload.content)!.payload as ContentPayload;
expect(contentPayload.text).toBe(longText);
});
test("meta is stored in thread-step payload", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const complexMeta = {
plan: "phase-1",
phases: [{ hash: "abc", title: "first" }],
nested: { deep: true },
};
const agentFn = createMockAgent({
planner: { text: "plan", meta: complexMeta },
coder: { text: "code", meta: { code: "done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "go",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD08"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
expect(step1.meta).toEqual(complexMeta);
expect(step2.meta).toEqual({ code: "done" });
});
});
describe("moderator routing", () => {
test("conditional moderator routes based on agent meta", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
let checkerCallCount = 0;
const agentFn: JsonCasAgentFn = async (role, _sp, _snap) => {
if (role === "checker") {
checkerCallCount++;
if (checkerCallCount === 1) {
return { text: "found issue", meta: { status: "bad" }, react: null };
}
return { text: "all good now", meta: { status: "ok" }, react: null };
}
return { text: "fixed it", meta: { fix: "patched" }, react: null };
};
const result = await executeJsonCasThread({
workflowHash,
input: "check and fix",
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD09"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
expect(checkerCallCount).toBe(2);
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const lastStep = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
expect(lastStep.role).toBe("checker");
const step2 = store.get(lastStep.previous!)!.payload as ThreadStepPayload;
expect(step2.role).toBe("fixer");
const step1 = store.get(step2.previous!)!.payload as ThreadStepPayload;
expect(step1.role).toBe("checker");
expect(step1.previous).toBeNull();
});
test("immediate END from moderator still produces a valid thread", async () => {
const { store, typeHashes } = await setupStore();
const immediateEnd: WorkflowInput = {
name: "test-immediate-end",
description: "Ends immediately",
roles: {
worker: {
description: "Never called",
systemPrompt: "N/A",
extractPrompt: "N/A",
schema: { type: "object" },
},
},
moderator: [{ from: START, to: END, when: null }],
};
const { workflowHash } = await setupWorkflow(store, typeHashes, immediateEnd);
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
throw new Error("should not be called");
};
const result = await executeJsonCasThread({
workflowHash,
input: "skip",
moderatorRules: immediateEnd.moderator,
io: makeIo(store, typeHashes, "THREAD10"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(0);
const endNode = store.get(result.rootHash);
expect(endNode).not.toBeNull();
const endPayload = endNode!.payload as ThreadEndPayload;
expect(endPayload.start).toBeTruthy();
expect(endPayload.lastStep).toBeTruthy();
});
});
describe("abort handling", () => {
test("aborted signal produces returnCode 130", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const ac = new AbortController();
ac.abort();
const agentFn: JsonCasAgentFn = async (): Promise<never> => {
throw new Error("should not be called");
};
const result = await executeJsonCasThread({
workflowHash,
input: "will abort",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD11"),
options: makeOptions({ signal: ac.signal }),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(result.returnCode).toBe(130);
expect(result.summary).toContain("abort");
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
expect(endPayload.returnCode).toBe(130);
});
});
describe("agent receives correct context", () => {
test("agent receives role name, system prompt, and accumulated steps", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const { loadWorkflow } = await import("@uncaged/workflow-json-def");
const hydrated = loadWorkflow(store, typeHashes, workflowHash);
const receivedCalls: Array<{
role: string;
systemPrompt: string;
stepCount: number;
input: string;
}> = [];
const agentFn: JsonCasAgentFn = async (role, systemPrompt, snapshot) => {
receivedCalls.push({
role,
systemPrompt,
stepCount: snapshot.steps.length,
input: snapshot.start.input,
});
return { text: `output for ${role}`, meta: {}, react: null };
};
await executeJsonCasThread({
workflowHash,
input: "my prompt",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD12"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: hydrated,
});
expect(receivedCalls.length).toBe(2);
expect(receivedCalls[0]!.role).toBe("planner");
expect(receivedCalls[0]!.systemPrompt).toBe("You are a planner.");
expect(receivedCalls[0]!.stepCount).toBe(0);
expect(receivedCalls[0]!.input).toBe("my prompt");
expect(receivedCalls[1]!.role).toBe("coder");
expect(receivedCalls[1]!.systemPrompt).toBe("You are a coder.");
expect(receivedCalls[1]!.stepCount).toBe(1);
});
test("snapshot accumulates step meta from previous rounds", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, CONDITIONAL_WORKFLOW);
let round = 0;
const snapshots: Array<{
role: string;
steps: readonly { role: string; meta: Record<string, unknown> }[];
}> = [];
const agentFn: JsonCasAgentFn = async (role, _sp, snapshot) => {
snapshots.push({ role, steps: [...snapshot.steps] });
round++;
if (role === "checker") {
return round === 1
? { text: "bad", meta: { status: "bad" }, react: null }
: { text: "ok", meta: { status: "ok" }, react: null };
}
return { text: "fixed", meta: { fix: "yes" }, react: null };
};
await executeJsonCasThread({
workflowHash,
input: "go",
moderatorRules: CONDITIONAL_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD13"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
expect(snapshots.length).toBe(3);
expect(snapshots[0]!.steps.length).toBe(0);
expect(snapshots[1]!.steps.length).toBe(1);
expect(snapshots[1]!.steps[0]!.role).toBe("checker");
expect(snapshots[1]!.steps[0]!.meta).toEqual({ status: "bad" });
expect(snapshots[2]!.steps.length).toBe(2);
expect(snapshots[2]!.steps[0]!.role).toBe("checker");
expect(snapshots[2]!.steps[1]!.role).toBe("fixer");
});
});
});
describe("buildJsonCasThreadSnapshot", () => {
test("builds snapshot from start + step chain", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan text", meta: { plan: "alpha" } },
coder: { text: "code text", meta: { code: "beta" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "build it",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_SNAP"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const startHash = endPayload.start;
const lastStepHash = endPayload.lastStep;
const snapshot = buildJsonCasThreadSnapshot(
store,
typeHashes,
startHash,
lastStepHash,
"THREAD_SNAP",
);
expect(snapshot.threadId).toBe("THREAD_SNAP");
expect(snapshot.start.input).toBe("build it");
expect(snapshot.start.workflowHash).toBe(workflowHash);
expect(snapshot.steps.length).toBe(2);
expect(snapshot.steps[0]!.role).toBe("planner");
expect(snapshot.steps[0]!.meta).toEqual({ plan: "alpha" });
expect(snapshot.steps[1]!.role).toBe("coder");
expect(snapshot.steps[1]!.meta).toEqual({ code: "beta" });
});
test("builds snapshot with null headStepHash (start only)", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const startHash = await store.put(typeHashes.threadStart, {
workflow: workflowHash,
input: "just started",
depth: 0,
parentThread: null,
agents: {},
});
const snapshot = buildJsonCasThreadSnapshot(store, typeHashes, startHash, null, "THREAD_SNAP2");
expect(snapshot.threadId).toBe("THREAD_SNAP2");
expect(snapshot.start.input).toBe("just started");
expect(snapshot.steps.length).toBe(0);
});
});
describe("buildJsonCasThreadContext", () => {
test("builds a protocol-compatible ThreadContext", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan text", meta: { plan: "ctx-test" } },
coder: { text: "code text", meta: { code: "ctx-done" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "context test",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_CTX"),
options: makeOptions({ depth: 3 }),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const ctx = buildJsonCasThreadContext(store, typeHashes, endPayload.start, endPayload.lastStep);
expect(ctx.threadId).toBe("");
expect(ctx.depth).toBe(3);
expect(ctx.bundleHash).toBe(workflowHash);
expect(ctx.start.role).toBe("__start__");
expect(ctx.start.content).toBe("context test");
expect(ctx.steps.length).toBe(2);
expect(ctx.steps[0]!.role).toBe("planner");
expect(ctx.steps[0]!.meta).toEqual({ plan: "ctx-test" });
expect(ctx.steps[1]!.role).toBe("coder");
expect(ctx.steps[1]!.meta).toEqual({ code: "ctx-done" });
});
test("context from start-only thread has empty steps", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const startHash = await store.put(typeHashes.threadStart, {
workflow: workflowHash,
input: "start only",
depth: 0,
parentThread: null,
agents: {},
});
const ctx = buildJsonCasThreadContext(store, typeHashes, startHash, null);
expect(ctx.start.content).toBe("start only");
expect(ctx.steps.length).toBe(0);
});
});
describe("readContentText", () => {
test("reads text from a content node", async () => {
const { store, typeHashes } = await setupStore();
const hash = await store.put(typeHashes.content, { text: "hello world" });
const text = readContentText(store, hash);
expect(text).toBe("hello world");
});
test("returns null for missing hash", async () => {
const { store } = await setupStore();
const text = readContentText(store, "NONEXISTENT0001");
expect(text).toBeNull();
});
});
describe("CAS graph integrity", () => {
test("all nodes are reachable via walk from thread-end", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SIMPLE_WORKFLOW);
const agentFn = createMockAgent({
planner: { text: "plan", meta: { plan: "x" } },
coder: { text: "code", meta: { code: "y" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "walk test",
moderatorRules: SIMPLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_WALK"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const visited = new Set<string>();
walk(store, result.rootHash, (hash) => {
visited.add(hash);
});
expect(visited.has(result.rootHash)).toBe(true);
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
expect(visited.has(endPayload.start)).toBe(true);
expect(visited.has(endPayload.lastStep)).toBe(true);
const step2 = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
expect(visited.has(step2.content)).toBe(true);
expect(visited.has(step2.react)).toBe(true);
expect(visited.has(step2.start)).toBe(true);
if (step2.previous !== null) {
expect(visited.has(step2.previous)).toBe(true);
const step1 = store.get(step2.previous)!.payload as ThreadStepPayload;
expect(visited.has(step1.content)).toBe(true);
expect(visited.has(step1.react)).toBe(true);
}
});
test("react session nodes have empty structure when agent returns react: null", async () => {
const { store, typeHashes } = await setupStore();
const { workflowHash } = await setupWorkflow(store, typeHashes, SINGLE_ROLE_WORKFLOW);
const agentFn = createMockAgent({
worker: { text: "w", meta: { result: "r" } },
});
const result = await executeJsonCasThread({
workflowHash,
input: "react check",
moderatorRules: SINGLE_ROLE_WORKFLOW.moderator,
io: makeIo(store, typeHashes, "THREAD_REACT"),
options: makeOptions(),
agentFn,
logger: noLogger(),
workflow: null,
});
const endPayload = store.get(result.rootHash)!.payload as ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!.payload as ThreadStepPayload;
const reactNode = store.get(stepPayload.react);
expect(reactNode).not.toBeNull();
const reactPayload = reactNode!.payload as Record<string, unknown>;
expect(reactPayload.turns).toEqual([]);
expect(reactPayload.totalTokens).toBe(0);
expect(reactPayload.durationMs).toBe(0);
expect(reactPayload.role).toBe("worker");
expect(typeof reactPayload.agent).toBe("string");
});
});
@@ -1,415 +0,0 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore } from "@uncaged/json-cas";
import {
type ContentPayload,
type ReactSessionPayload,
type ReactToolCallPayload,
type ReactTurnPayload,
registerWorkflowSchemas,
} from "@uncaged/json-cas-workflow";
import { writeReactSession } from "../src/engine/json-cas-react-recorder.js";
import type { ReactTrace } from "../src/engine/json-cas-types.js";
// ── Fixtures ──────────────────────────────────────────────────────────
async function setupStore() {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
return { store, typeHashes };
}
async function makeFakeAgent(
store: Awaited<ReturnType<typeof setupStore>>["store"],
typeHashes: Awaited<ReturnType<typeof setupStore>>["typeHashes"],
) {
return store.put(typeHashes.agent, {
package: "test-agent",
version: "1.0.0",
config: {},
});
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("writeReactSession", () => {
describe("empty trace", () => {
test("produces a react-session with zero turns", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = { turns: [], totalTokens: 0, durationMs: 0 };
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "worker",
trace,
});
const node = store.get(sessionHash);
expect(node).not.toBeNull();
const payload = node!.payload as ReactSessionPayload;
expect(payload.agent).toBe(agentHash);
expect(payload.role).toBe("worker");
expect(payload.turns).toEqual([]);
expect(payload.totalTokens).toBe(0);
expect(payload.durationMs).toBe(0);
});
});
describe("single turn, no tool calls", () => {
test("produces react-session → react-turn → content nodes", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "What is 2+2?",
output: "4",
toolCalls: [],
tokens: { input: 10, output: 5 },
latencyMs: 200,
},
],
totalTokens: 15,
durationMs: 200,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "solver",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
expect(session.turns.length).toBe(1);
expect(session.totalTokens).toBe(15);
expect(session.durationMs).toBe(200);
expect(session.role).toBe("solver");
const turnHash = session.turns[0]!;
const turn = store.get(turnHash)!.payload as ReactTurnPayload;
expect(turn.toolCalls).toEqual([]);
expect(turn.tokens).toEqual({ input: 10, output: 5 });
expect(turn.latencyMs).toBe(200);
const inputContent = store.get(turn.input)!.payload as ContentPayload;
expect(inputContent.text).toBe("What is 2+2?");
const outputContent = store.get(turn.output)!.payload as ContentPayload;
expect(outputContent.text).toBe("4");
});
});
describe("single turn with tool calls", () => {
test("serialises tool calls to react-tool-call → content nodes", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "Search for cats",
output: "Found 42 cats",
toolCalls: [
{
name: "search",
arguments: '{"query":"cats"}',
result: '{"count":42}',
durationMs: 80,
},
],
tokens: { input: 20, output: 10 },
latencyMs: 350,
},
],
totalTokens: 30,
durationMs: 350,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "searcher",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.toolCalls.length).toBe(1);
const toolCall = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
expect(toolCall.name).toBe("search");
expect(toolCall.durationMs).toBe(80);
const argsContent = store.get(toolCall.arguments)!.payload as ContentPayload;
expect(argsContent.text).toBe('{"query":"cats"}');
const resultContent = store.get(toolCall.result)!.payload as ContentPayload;
expect(resultContent.text).toBe('{"count":42}');
});
test("multiple tool calls in one turn are all recorded", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "Do two things",
output: "Done",
toolCalls: [
{ name: "tool_a", arguments: '{"x":1}', result: '"ok_a"', durationMs: 10 },
{ name: "tool_b", arguments: '{"y":2}', result: '"ok_b"', durationMs: 20 },
],
tokens: { input: 5, output: 3 },
latencyMs: 100,
},
],
totalTokens: 8,
durationMs: 100,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "doer",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.toolCalls.length).toBe(2);
const tc0 = store.get(turn.toolCalls[0]!)!.payload as ReactToolCallPayload;
expect(tc0.name).toBe("tool_a");
const tc1 = store.get(turn.toolCalls[1]!)!.payload as ReactToolCallPayload;
expect(tc1.name).toBe("tool_b");
});
});
describe("multiple turns", () => {
test("each turn is stored as a separate react-turn node", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "Round 1 prompt",
output: "Round 1 response",
toolCalls: [],
tokens: { input: 10, output: 8 },
latencyMs: 100,
},
{
input: "Round 2 prompt",
output: "Round 2 response",
toolCalls: [],
tokens: { input: 12, output: 6 },
latencyMs: 120,
},
],
totalTokens: 36,
durationMs: 220,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "multi",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
expect(session.turns.length).toBe(2);
expect(session.totalTokens).toBe(36);
expect(session.durationMs).toBe(220);
// Turns must be distinct nodes
expect(session.turns[0]).not.toBe(session.turns[1]);
const turn0 = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect((store.get(turn0.input)!.payload as ContentPayload).text).toBe("Round 1 prompt");
expect(turn0.tokens).toEqual({ input: 10, output: 8 });
const turn1 = store.get(session.turns[1]!)!.payload as ReactTurnPayload;
expect((store.get(turn1.input)!.payload as ContentPayload).text).toBe("Round 2 prompt");
expect(turn1.tokens).toEqual({ input: 12, output: 6 });
});
});
describe("token and duration values", () => {
test("token counts and latency are preserved exactly", async () => {
const { store, typeHashes } = await setupStore();
const agentHash = await makeFakeAgent(store, typeHashes);
const trace: ReactTrace = {
turns: [
{
input: "p",
output: "r",
toolCalls: [],
tokens: { input: 9999, output: 1234 },
latencyMs: 5678,
},
],
totalTokens: 11233,
durationMs: 5678,
};
const sessionHash = await writeReactSession(store, typeHashes, {
agentHash,
role: "counter",
trace,
});
const session = store.get(sessionHash)!.payload as ReactSessionPayload;
expect(session.totalTokens).toBe(11233);
expect(session.durationMs).toBe(5678);
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.tokens.input).toBe(9999);
expect(turn.tokens.output).toBe(1234);
expect(turn.latencyMs).toBe(5678);
});
});
});
describe("writeReactSession + executeJsonCasThread integration", () => {
test("engine stores real react session when agent provides react trace", async () => {
const { store, typeHashes } = await setupStore();
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
const workflowHash = await registerWorkflow(store, typeHashes, {
name: "react-test",
description: "Tests react instrumentation",
roles: {
solver: {
description: "Solves",
systemPrompt: "Solve it.",
extractPrompt: "Extract.",
schema: {
type: "object",
required: ["answer"],
properties: { answer: { type: "string" } },
},
},
},
moderator: [
{ from: "__start__", to: "solver", when: null },
{ from: "solver", to: "__end__", when: null },
],
});
const agentFn: JsonCasAgentFn = async () => ({
text: "The answer is 42",
meta: { answer: "42" },
react: {
turns: [
{
input: "Solve it. What is the answer?",
output: "The answer is 42",
toolCalls: [],
tokens: { input: 15, output: 8 },
latencyMs: 300,
},
],
totalTokens: 23,
durationMs: 300,
},
});
const result = await executeJsonCasThread({
workflowHash,
input: "What is the answer?",
moderatorRules: [
{ from: "__start__", to: "solver", when: null },
{ from: "solver", to: "__end__", when: null },
],
io: { threadId: "REACT_INTEG", store, typeHashes },
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
agentFn,
logger: () => {},
workflow: null,
});
const endPayload = store.get(result.rootHash)!
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
expect(session.turns.length).toBe(1);
expect(session.totalTokens).toBe(23);
expect(session.durationMs).toBe(300);
expect(session.role).toBe("solver");
const turn = store.get(session.turns[0]!)!.payload as ReactTurnPayload;
expect(turn.tokens).toEqual({ input: 15, output: 8 });
expect(turn.latencyMs).toBe(300);
expect((store.get(turn.input)!.payload as ContentPayload).text).toBe(
"Solve it. What is the answer?",
);
});
test("engine falls back to empty react-session when react is null", async () => {
const { store, typeHashes } = await setupStore();
const { registerWorkflow } = await import("@uncaged/workflow-json-def");
const { executeJsonCasThread } = await import("../src/engine/json-cas-engine.js");
type JsonCasAgentFn = import("../src/engine/json-cas-types.js").JsonCasAgentFn;
const workflowHash = await registerWorkflow(store, typeHashes, {
name: "null-react-test",
description: "Tests null react fallback",
roles: {
worker: {
description: "Works",
systemPrompt: "Work.",
extractPrompt: "Extract.",
schema: {
type: "object",
required: ["result"],
properties: { result: { type: "string" } },
},
},
},
moderator: [
{ from: "__start__", to: "worker", when: null },
{ from: "worker", to: "__end__", when: null },
],
});
const agentFn: JsonCasAgentFn = async () => ({
text: "done",
meta: { result: "done" },
react: null,
});
const result = await executeJsonCasThread({
workflowHash,
input: "do it",
moderatorRules: [
{ from: "__start__", to: "worker", when: null },
{ from: "worker", to: "__end__", when: null },
],
io: { threadId: "NULL_REACT", store, typeHashes },
options: { depth: 0, parentThread: null, signal: new AbortController().signal, agents: {} },
agentFn,
logger: () => {},
workflow: null,
});
const endPayload = store.get(result.rootHash)!
.payload as import("@uncaged/json-cas-workflow").ThreadEndPayload;
const stepPayload = store.get(endPayload.lastStep)!
.payload as import("@uncaged/json-cas-workflow").ThreadStepPayload;
const session = store.get(stepPayload.react)!.payload as ReactSessionPayload;
expect(session.turns).toEqual([]);
expect(session.totalTokens).toBe(0);
expect(session.durationMs).toBe(0);
expect(session.role).toBe("worker");
});
});
-3
View File
@@ -24,9 +24,6 @@
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-reactor": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/json-cas": "^0.1.0",
"@uncaged/json-cas-workflow": "^0.1.0",
"@uncaged/workflow-json-def": "workspace:^",
"yaml": "^2.7.1"
},
"peerDependencies": {
@@ -7,27 +7,6 @@ export {
walkStateFramesNewestFirst,
} from "./fork-thread.js";
export { garbageCollectCas } from "./gc.js";
export {
buildJsonCasThreadContext,
buildJsonCasThreadSnapshot,
readContentText,
} from "./json-cas-context.js";
export { executeJsonCasThread } from "./json-cas-engine.js";
export { writeReactSession } from "./json-cas-react-recorder.js";
export type {
AgentBindings,
JsonCasAgentFn,
JsonCasAgentResult,
JsonCasEngineIo,
JsonCasEngineOptions,
JsonCasStartSnapshot,
JsonCasStepSnapshot,
JsonCasThreadPauseGate,
JsonCasThreadSnapshot,
ReactToolCallTrace,
ReactTrace,
ReactTurnTrace,
} from "./json-cas-types.js";
export { createThreadPauseGate } from "./thread-pause-gate.js";
export type { ThreadHistoryEntry, ThreadIndex, ThreadIndexEntry } from "./threads-index.js";
export {
@@ -1,130 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { ThreadContext } from "@uncaged/workflow-protocol";
import { START } from "@uncaged/workflow-protocol";
import type { JsonCasStepSnapshot, JsonCasThreadSnapshot } from "./json-cas-types.js";
// ── Snapshot builder (lightweight, for agent & moderator) ─────────────
/**
* Walk the thread-step chain backwards via `previous` refs, then reverse
* to get chronological order. Returns a {@link JsonCasThreadSnapshot}.
*/
export function buildJsonCasThreadSnapshot(
store: Store,
_typeHashes: WorkflowSchemaHashes,
startHash: Hash,
headStepHash: Hash | null,
threadId: string,
): JsonCasThreadSnapshot {
const startNode = store.get(startHash);
if (startNode === null) {
throw new Error(`buildJsonCasThreadSnapshot: missing thread-start node at ${startHash}`);
}
const startPayload = startNode.payload as ThreadStartPayload;
const steps: JsonCasStepSnapshot[] = [];
let cursor: Hash | null = headStepHash;
while (cursor !== null) {
const stepNode = store.get(cursor);
if (stepNode === null) {
throw new Error(`buildJsonCasThreadSnapshot: missing thread-step node at ${cursor}`);
}
const stepPayload = stepNode.payload as ThreadStepPayload;
steps.push({
role: stepPayload.role,
meta: stepPayload.meta,
contentHash: stepPayload.content,
});
cursor = stepPayload.previous;
}
steps.reverse();
return {
threadId,
start: {
input: startPayload.input,
depth: startPayload.depth,
workflowHash: startPayload.workflow,
},
steps,
};
}
// ── ThreadContext builder (protocol-compatible) ───────────────────────
/**
* Build a full {@link ThreadContext} from a json-cas thread chain.
* Reads the thread-start node, walks thread-step backwards, and resolves
* content text from each step's content node.
*
* `bundleHash` is set from the workflow ref in the thread-start payload.
* `threadId` is set to `""` — callers should overwrite when known.
*/
export function buildJsonCasThreadContext(
store: Store,
_typeHashes: WorkflowSchemaHashes,
startHash: Hash,
headStepHash: Hash | null,
): ThreadContext {
const startNode = store.get(startHash);
if (startNode === null) {
throw new Error(`buildJsonCasThreadContext: missing thread-start node at ${startHash}`);
}
const startPayload = startNode.payload as ThreadStartPayload;
const rawSteps: ThreadStepPayload[] = [];
let cursor: Hash | null = headStepHash;
while (cursor !== null) {
const stepNode = store.get(cursor);
if (stepNode === null) {
throw new Error(`buildJsonCasThreadContext: missing thread-step node at ${cursor}`);
}
const payload = stepNode.payload as ThreadStepPayload;
rawSteps.push(payload);
cursor = payload.previous;
}
rawSteps.reverse();
const steps = rawSteps.map((sp) => ({
role: sp.role,
meta: sp.meta,
contentHash: sp.content,
refs: [] as string[],
timestamp: 0,
}));
return {
threadId: "",
depth: startPayload.depth,
bundleHash: startPayload.workflow,
start: {
role: START,
content: startPayload.input,
meta: {},
timestamp: 0,
parentState: startPayload.parentThread,
},
steps,
};
}
/**
* Read the text payload from a content node.
*/
export function readContentText(store: Store, contentHash: Hash): string | null {
const node = store.get(contentHash);
if (node === null) {
return null;
}
const payload = node.payload as ContentPayload;
return payload.text;
}
@@ -1,326 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ThreadEndPayload,
ThreadStartPayload,
ThreadStepPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { HydratedWorkflow } from "@uncaged/workflow-json-def";
import type { ModeratorRule, WorkflowResult } from "@uncaged/workflow-protocol";
import { END, evaluateModerator, START } from "@uncaged/workflow-protocol";
import type { LogFn } from "@uncaged/workflow-util";
import { writeReactSession } from "./json-cas-react-recorder.js";
import type {
AgentBindings,
JsonCasAgentFn,
JsonCasEngineIo,
JsonCasEngineOptions,
JsonCasStepSnapshot,
JsonCasThreadSnapshot,
} from "./json-cas-types.js";
// ── Helpers: CAS node writers ─────────────────────────────────────────
async function writeContent(
store: Store,
typeHashes: WorkflowSchemaHashes,
text: string,
): Promise<Hash> {
const payload: ContentPayload = { text };
return store.put(typeHashes.content, payload);
}
async function writeEmptyReactSession(
store: Store,
typeHashes: WorkflowSchemaHashes,
role: string,
agentHash: Hash,
): Promise<Hash> {
return store.put(typeHashes.reactSession, {
agent: agentHash,
role,
turns: [],
totalTokens: 0,
durationMs: 0,
});
}
async function writeThreadStart(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
workflowHash: Hash;
input: string;
depth: number;
parentThread: Hash | null;
agents: AgentBindings;
},
): Promise<Hash> {
const payload: ThreadStartPayload = {
workflow: params.workflowHash,
input: params.input,
depth: params.depth,
parentThread: params.parentThread,
agents: params.agents,
};
return store.put(typeHashes.threadStart, payload);
}
async function writeThreadStep(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
role: string;
meta: Record<string, unknown>;
contentHash: Hash;
reactHash: Hash;
startHash: Hash;
previousHash: Hash | null;
},
): Promise<Hash> {
const payload: ThreadStepPayload = {
role: params.role,
meta: params.meta,
content: params.contentHash,
react: params.reactHash,
start: params.startHash,
previous: params.previousHash,
};
return store.put(typeHashes.threadStep, payload);
}
async function writeThreadEnd(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
returnCode: number;
summary: string;
startHash: Hash;
lastStepHash: Hash;
},
): Promise<Hash> {
const payload: ThreadEndPayload = {
returnCode: params.returnCode,
summary: params.summary,
start: params.startHash,
lastStep: params.lastStepHash,
};
return store.put(typeHashes.threadEnd, payload);
}
// ── Placeholder agent ─────────────────────────────────────────────────
async function ensurePlaceholderAgent(
store: Store,
typeHashes: WorkflowSchemaHashes,
): Promise<Hash> {
return store.put(typeHashes.agent, {
package: "placeholder",
version: "0.0.0",
config: {},
});
}
// ── JSONata moderator adapter ─────────────────────────────────────────
function snapshotToModeratorContext(
snapshot: JsonCasThreadSnapshot,
): Parameters<typeof evaluateModerator>[1] {
return {
threadId: snapshot.threadId,
depth: snapshot.start.depth,
bundleHash: snapshot.start.workflowHash,
start: {
role: START,
content: snapshot.start.input,
meta: {},
timestamp: 0,
parentState: null,
},
steps: snapshot.steps.map((s) => ({
role: s.role,
meta: s.meta,
contentHash: s.contentHash,
refs: [],
timestamp: 0,
})),
};
}
// ── Main engine ───────────────────────────────────────────────────────
/**
* Execute a workflow thread using json-cas as the storage layer.
*
* Drives the moderator→agent loop:
* 1. Writes a thread-start node.
* 2. On each round: evaluates the moderator, invokes the agent, writes
* content + thread-step nodes (react is a placeholder for now).
* 3. On END: writes a thread-end node and returns the result.
*
* The `agentFn` callback is invoked for each role step. It receives the
* role name, system prompt, and current thread snapshot, and returns the
* agent's text output plus structured meta.
*/
export async function executeJsonCasThread(params: {
workflowHash: Hash;
input: string;
moderatorRules: readonly ModeratorRule[];
io: JsonCasEngineIo;
options: JsonCasEngineOptions;
agentFn: JsonCasAgentFn;
logger: LogFn;
/** Hydrated workflow for role system prompts. Null disables prompt forwarding. */
workflow: HydratedWorkflow | null;
}): Promise<WorkflowResult> {
const { io, options, agentFn, logger, moderatorRules, workflow } = params;
const { store, typeHashes, threadId } = io;
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
const startHash = await writeThreadStart(store, typeHashes, {
workflowHash: params.workflowHash,
input: params.input,
depth: options.depth,
parentThread: options.parentThread,
agents: options.agents,
});
logger("X3RK7QWN", `json-cas thread ${threadId} started`);
let previousStepHash: Hash | null = null;
let headStepHash: Hash | null = null;
const stepSnapshots: JsonCasStepSnapshot[] = [];
while (true) {
if (options.signal.aborted) {
return abortThread(store, typeHashes, startHash, headStepHash, logger, threadId);
}
const snapshot: JsonCasThreadSnapshot = {
threadId,
start: {
input: params.input,
depth: options.depth,
workflowHash: params.workflowHash,
},
steps: stepSnapshots,
};
const modCtx = snapshotToModeratorContext(snapshot);
const nextRole = await evaluateModerator(moderatorRules, modCtx);
if (nextRole === END) {
logger("Y5TN8RVK", `json-cas thread ${threadId} moderator returned END`);
if (headStepHash === null) {
const dummyContentHash = await writeContent(store, typeHashes, "no-op");
const dummyReactHash = await writeEmptyReactSession(
store,
typeHashes,
END,
placeholderAgentHash,
);
headStepHash = await writeThreadStep(store, typeHashes, {
role: END,
meta: {},
contentHash: dummyContentHash,
reactHash: dummyReactHash,
startHash,
previousHash: null,
});
}
const endHash = await writeThreadEnd(store, typeHashes, {
returnCode: 0,
summary: "completed: moderator returned END",
startHash,
lastStepHash: headStepHash,
});
return { returnCode: 0, summary: "completed: moderator returned END", rootHash: endHash };
}
const roleSystemPrompt =
workflow !== null && workflow.roles[nextRole] !== undefined
? workflow.roles[nextRole].systemPrompt
: "";
const agentResult = await agentFn(nextRole, roleSystemPrompt, snapshot);
const contentHash = await writeContent(store, typeHashes, agentResult.text);
const agentHash = options.agents[nextRole] ?? placeholderAgentHash;
const reactHash =
agentResult.react !== null
? await writeReactSession(store, typeHashes, {
agentHash,
role: nextRole,
trace: agentResult.react,
})
: await writeEmptyReactSession(store, typeHashes, nextRole, agentHash);
const stepHash = await writeThreadStep(store, typeHashes, {
role: nextRole,
meta: agentResult.meta,
contentHash,
reactHash,
startHash,
previousHash: previousStepHash,
});
previousStepHash = stepHash;
headStepHash = stepHash;
stepSnapshots.push({
role: nextRole,
meta: agentResult.meta,
contentHash,
});
logger("Z7WP4NHK", `json-cas thread ${threadId} wrote role ${nextRole}`);
}
}
async function abortThread(
store: Store,
typeHashes: WorkflowSchemaHashes,
startHash: Hash,
headStepHash: Hash | null,
logger: LogFn,
threadId: string,
): Promise<WorkflowResult> {
logger("A8QK3VNR", `json-cas thread ${threadId} aborted`);
const placeholderAgentHash = await ensurePlaceholderAgent(store, typeHashes);
let lastStep = headStepHash;
if (lastStep === null) {
const dummyContentHash = await writeContent(store, typeHashes, "thread aborted");
const dummyReactHash = await writeEmptyReactSession(
store,
typeHashes,
END,
placeholderAgentHash,
);
lastStep = await writeThreadStep(store, typeHashes, {
role: END,
meta: {},
contentHash: dummyContentHash,
reactHash: dummyReactHash,
startHash,
previousHash: null,
});
}
const endHash = await writeThreadEnd(store, typeHashes, {
returnCode: 130,
summary: "thread aborted",
startHash,
lastStepHash: lastStep,
});
return { returnCode: 130, summary: "thread aborted", rootHash: endHash };
}
@@ -1,92 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
ContentPayload,
ReactSessionPayload,
ReactToolCallPayload,
ReactTurnPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { ReactToolCallTrace, ReactTrace, ReactTurnTrace } from "./json-cas-types.js";
// ── Node writers ──────────────────────────────────────────────────────
async function writeContent(
store: Store,
typeHashes: WorkflowSchemaHashes,
text: string,
): Promise<Hash> {
const payload: ContentPayload = { text };
return store.put(typeHashes.content, payload);
}
async function writeToolCall(
store: Store,
typeHashes: WorkflowSchemaHashes,
toolCall: ReactToolCallTrace,
): Promise<Hash> {
const [argsHash, resultHash] = await Promise.all([
writeContent(store, typeHashes, toolCall.arguments),
writeContent(store, typeHashes, toolCall.result),
]);
const payload: ReactToolCallPayload = {
name: toolCall.name,
arguments: argsHash,
result: resultHash,
durationMs: toolCall.durationMs,
};
return store.put(typeHashes.reactToolCall, payload);
}
async function writeTurn(
store: Store,
typeHashes: WorkflowSchemaHashes,
turn: ReactTurnTrace,
): Promise<Hash> {
const [inputHash, outputHash, toolCallHashes] = await Promise.all([
writeContent(store, typeHashes, turn.input),
writeContent(store, typeHashes, turn.output),
Promise.all(turn.toolCalls.map((tc) => writeToolCall(store, typeHashes, tc))),
]);
const payload: ReactTurnPayload = {
input: inputHash,
output: outputHash,
toolCalls: toolCallHashes,
tokens: turn.tokens,
latencyMs: turn.latencyMs,
};
return store.put(typeHashes.reactTurn, payload);
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Serialise a {@link ReactTrace} captured during an agent run into CAS nodes:
*
* content (args/result) → react-tool-call
* content (input/output) + react-tool-calls → react-turn
* react-turns → react-session
*
* Returns the hash of the written react-session node.
*/
export async function writeReactSession(
store: Store,
typeHashes: WorkflowSchemaHashes,
params: {
agentHash: Hash;
role: string;
trace: ReactTrace;
},
): Promise<Hash> {
const turnHashes = await Promise.all(
params.trace.turns.map((turn) => writeTurn(store, typeHashes, turn)),
);
const payload: ReactSessionPayload = {
agent: params.agentHash,
role: params.role,
turns: turnHashes,
totalTokens: params.trace.totalTokens,
durationMs: params.trace.durationMs,
};
return store.put(typeHashes.reactSession, payload);
}
@@ -1,110 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import type { Result } from "@uncaged/workflow-util";
// ── Engine IO ─────────────────────────────────────────────────────────
export type JsonCasEngineIo = {
threadId: string;
store: Store;
typeHashes: WorkflowSchemaHashes;
};
// ── Agent binding ─────────────────────────────────────────────────────
/**
* Maps each role name to a CAS hash referencing an agent node.
* Phase 4 uses a simple role→hash mapping; full agent resolution comes later.
*/
export type AgentBindings = Record<string, Hash>;
// ── Engine options ────────────────────────────────────────────────────
export type JsonCasEngineOptions = {
depth: number;
parentThread: Hash | null;
signal: AbortSignal;
agents: AgentBindings;
};
// ── React trace (raw data before CAS serialisation) ───────────────────
export type ReactToolCallTrace = {
name: string;
/** JSON-serialised arguments */
arguments: string;
/** JSON-serialised result */
result: string;
durationMs: number;
};
export type ReactTurnTrace = {
/** Full prompt text sent to the LLM */
input: string;
/** Raw assistant response text */
output: string;
toolCalls: ReactToolCallTrace[];
tokens: { input: number; output: number };
latencyMs: number;
};
export type ReactTrace = {
turns: ReactTurnTrace[];
totalTokens: number;
durationMs: number;
};
// ── Agent function result ─────────────────────────────────────────────
export type JsonCasAgentResult = {
text: string;
meta: Record<string, unknown>;
/**
* React trace captured during the agent run.
* Null when the agent has no trace to record (e.g. a mock or passthrough).
*/
react: ReactTrace | null;
};
// ── Agent function (mock-friendly) ────────────────────────────────────
/**
* Invoked for each role step. Returns the agent's raw text output,
* structured meta, and an optional react trace. The engine stores the
* text in a content node and the trace in react-* CAS nodes.
*/
export type JsonCasAgentFn = (
role: string,
systemPrompt: string,
context: JsonCasThreadSnapshot,
) => Promise<JsonCasAgentResult>;
// ── Thread snapshot (read-only view for agents & moderator) ───────────
export type JsonCasStartSnapshot = {
input: string;
depth: number;
workflowHash: Hash;
};
export type JsonCasStepSnapshot = {
role: string;
meta: Record<string, unknown>;
contentHash: Hash;
};
export type JsonCasThreadSnapshot = {
threadId: string;
start: JsonCasStartSnapshot;
steps: readonly JsonCasStepSnapshot[];
};
// ── Thread pause gate (re-use from existing types) ────────────────────
export type JsonCasThreadPauseGate = {
awaitAfterYield: () => Promise<void>;
pause: () => Result<void, string>;
resume: () => Result<void, string>;
isPaused: () => boolean;
};
@@ -3,7 +3,9 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { dirname, join } from "node:path";
import { createCasStore } from "@uncaged/workflow-cas";
import { importWorkflowBundleModule } from "@uncaged/workflow-register";
import {
importWorkflowBundleModule,
} from "@uncaged/workflow-register";
import type { RoleOutput, WorkflowFn } from "@uncaged/workflow-runtime";
import {
createLogger,
-12
View File
@@ -4,18 +4,6 @@ export {
walkStateFramesNewestFirst,
} from "./engine/fork-thread.js";
export { garbageCollectCas } from "./engine/gc.js";
export { buildJsonCasThreadContext, buildJsonCasThreadSnapshot, readContentText } from "./engine/json-cas-context.js";
export { executeJsonCasThread } from "./engine/json-cas-engine.js";
export type {
AgentBindings,
JsonCasAgentFn,
JsonCasEngineIo,
JsonCasEngineOptions,
JsonCasStartSnapshot,
JsonCasStepSnapshot,
JsonCasThreadPauseGate,
JsonCasThreadSnapshot,
} from "./engine/json-cas-types.js";
export type {
ThreadHistoryEntry,
ThreadIndex,
+1 -2
View File
@@ -11,7 +11,6 @@
{ "path": "../workflow-util" },
{ "path": "../workflow-cas" },
{ "path": "../workflow-reactor" },
{ "path": "../workflow-register" },
{ "path": "../workflow-json-def" }
{ "path": "../workflow-register" }
]
}
@@ -1,238 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import { createMemoryStore, refs, validate } from "@uncaged/json-cas";
import type { ThreadStartPayload } from "@uncaged/json-cas-workflow";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
import { putAgentNode } from "../src/index.js";
// ── Step 6: putAgentNode — CAS agent instance nodes ──────────────────────────
describe("Step 6: putAgentNode", () => {
test("returns a 13-char Crockford Base32 hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{ baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" },
);
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("stored agent node is present in the store", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-cursor",
"0.5.0-alpha.4",
{
command: "/usr/bin/cursor-agent",
model: null,
timeout: 0,
workspace: null,
},
);
expect(store.get(hash)).not.toBeNull();
});
test("agent node payload contains package, version, and config", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const config = { command: "/usr/bin/hermes", model: "claude-3-5-sonnet", timeout: null };
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-hermes",
"0.5.0-alpha.4",
config,
);
const node = store.get(hash) as CasNode;
const payload = node.payload as Record<string, unknown>;
expect(payload.package).toBe("@uncaged/workflow-agent-hermes");
expect(payload.version).toBe("0.5.0-alpha.4");
expect(payload.config).toEqual(config);
});
test("idempotent: same package + version + config returns the same hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const config = { baseUrl: "https://api.example.com", apiKey: "sk-test", model: "gpt-4o" };
const hash1 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
config,
);
const hash2 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
config,
);
expect(hash1).toBe(hash2);
});
test("different configs produce different hashes", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash1 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{
baseUrl: "https://api.example.com",
apiKey: "sk-test",
model: "gpt-4o",
},
);
const hash2 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{
baseUrl: "https://api.example.com",
apiKey: "sk-test",
model: "gpt-4o-mini",
},
);
expect(hash1).not.toBe(hash2);
});
test("agent node passes validation against the agent schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-react",
"0.5.0-alpha.4",
{
maxRounds: 10,
},
);
const node = store.get(hash) as CasNode;
expect(validate(store, node)).toBe(true);
});
test("agent node with empty config is valid", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await putAgentNode(store, typeHashes, "placeholder", "0.0.0", {});
const node = store.get(hash) as CasNode;
expect(validate(store, node)).toBe(true);
});
});
// ── Step 6: refs from thread-start includes agent refs ────────────────────────
describe("Step 6: refs() from thread-start extracts agent refs", () => {
test("thread-start with agents: refs() returns the agent hashes", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const agentHash1 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-llm",
"0.5.0-alpha.4",
{ baseUrl: "https://api.example.com", apiKey: "sk-1", model: "gpt-4o" },
);
const agentHash2 = await putAgentNode(
store,
typeHashes,
"@uncaged/workflow-agent-cursor",
"0.5.0-alpha.4",
{ command: "/usr/bin/cursor-agent", model: null, timeout: 0, workspace: null },
);
const fakeWorkflowHash = "FAKEWF0000001";
const startHash = await store.put(typeHashes.threadStart, {
workflow: fakeWorkflowHash,
input: "test",
depth: 0,
parentThread: null,
agents: { planner: agentHash1, coder: agentHash2 },
} satisfies ThreadStartPayload);
const startNode = store.get(startHash) as CasNode;
const startRefs = refs(store, startNode);
expect(startRefs).toContain(agentHash1);
expect(startRefs).toContain(agentHash2);
});
test("thread-start with no agents: refs() returns only the workflow ref", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const fakeWorkflowHash = "FAKEWF0000002";
const startHash = await store.put(typeHashes.threadStart, {
workflow: fakeWorkflowHash,
input: "empty agents",
depth: 0,
parentThread: null,
agents: {},
} satisfies ThreadStartPayload);
const startNode = store.get(startHash) as CasNode;
const startRefs = refs(store, startNode);
expect(startRefs).toContain(fakeWorkflowHash);
expect(startRefs).toHaveLength(1);
});
test("thread-start with 3 agents: refs() count includes workflow + 3 agents", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const makeAgent = (model: string) =>
putAgentNode(store, typeHashes, "@uncaged/workflow-agent-llm", "0.5.0-alpha.4", {
baseUrl: "https://api.example.com",
apiKey: "sk-x",
model,
});
const [a1, a2, a3] = await Promise.all([makeAgent("m1"), makeAgent("m2"), makeAgent("m3")]);
const fakeWorkflowHash = "FAKEWF0000003";
const startHash = await store.put(typeHashes.threadStart, {
workflow: fakeWorkflowHash,
input: "multi-agent",
depth: 0,
parentThread: null,
agents: { r1: a1, r2: a2, r3: a3 },
} satisfies ThreadStartPayload);
const startNode = store.get(startHash) as CasNode;
const startRefs = refs(store, startNode);
// 1 workflow ref + 3 agent refs = 4
expect(startRefs).toHaveLength(4);
expect(startRefs).toContain(fakeWorkflowHash);
expect(startRefs).toContain(a1);
expect(startRefs).toContain(a2);
expect(startRefs).toContain(a3);
});
});
@@ -1,403 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { CasNode } from "@uncaged/json-cas";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import { registerWorkflowSchemas } from "@uncaged/json-cas-workflow";
import {
developWorkflow,
END,
loadWorkflow,
registerWorkflow,
START,
solveIssueWorkflow,
} from "../src/index.js";
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: Bootstrap — registerWorkflowSchemas returns all 11 schema hashes
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 1: registerWorkflowSchemas", () => {
test("returns 11 distinct 13-char Crockford Base32 hashes", async () => {
const store = createMemoryStore();
const hashes = await registerWorkflowSchemas(store);
const values = Object.values(hashes);
expect(values).toHaveLength(11);
for (const h of values) {
expect(h).toHaveLength(13);
expect(h).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
}
expect(new Set(values).size).toBe(11);
});
test("is idempotent across multiple calls", async () => {
const store = createMemoryStore();
const first = await registerWorkflowSchemas(store);
const second = await registerWorkflowSchemas(store);
for (const key of Object.keys(first) as (keyof typeof first)[]) {
expect(first[key]).toBe(second[key]);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: registerWorkflow — stores roles + workflow in CAS
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 2: registerWorkflow", () => {
test("returns a 13-char Crockford Base32 workflow hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(hash).toHaveLength(13);
expect(hash).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/);
});
test("is idempotent: registering the same workflow twice returns the same hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash1 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const hash2 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(hash1).toBe(hash2);
});
test("workflow node is present in the store after registration", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(store.get(hash)).not.toBeNull();
});
test("stores role nodes — one per role in the definition", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
expect(Object.keys(roles)).toHaveLength(Object.keys(solveIssueWorkflow.roles).length);
for (const roleHash of Object.values(roles)) {
expect(store.get(roleHash)).not.toBeNull();
}
});
test("stores role-schema nodes — one per role", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
const roleNode = store.get(roleHash) as CasNode;
const schemaHash = (roleNode.payload as Record<string, string>).schema;
expect(store.get(schemaHash)).not.toBeNull();
}
});
test("workflow payload contains correct name and description", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const node = store.get(hash) as CasNode;
const payload = node.payload as Record<string, unknown>;
expect(payload.name).toBe("develop");
expect(payload.description).toBe(developWorkflow.description);
});
test("workflow payload contains moderator rules array", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const node = store.get(hash) as CasNode;
const payload = node.payload as Record<string, unknown>;
expect(Array.isArray(payload.moderator)).toBe(true);
const rules = payload.moderator as Array<{ from: string; to: string; when: string | null }>;
expect(rules.some((r) => r.from === START)).toBe(true);
expect(rules.some((r) => r.to === END)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 3: loadWorkflow — round-trip hydration from CAS
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 3: loadWorkflow", () => {
test("returns null for an unknown hash", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
expect(loadWorkflow(store, typeHashes, "AAAAAAAAAAAAA")).toBeNull();
});
test("hydrates solve-issue workflow with correct name and description", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
expect(result).not.toBeNull();
expect(result?.name).toBe("solve-issue");
expect(result?.description).toBe(solveIssueWorkflow.description);
});
test("hydrated workflow contains all roles", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
const expectedRoles = Object.keys(solveIssueWorkflow.roles);
expect(Object.keys(result?.roles ?? {})).toEqual(expect.arrayContaining(expectedRoles));
});
test("hydrated role has correct systemPrompt and description", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
const preparer = result?.roles.preparer;
expect(preparer?.description).toBe(solveIssueWorkflow.roles.preparer.description);
expect(preparer?.systemPrompt).toBe(solveIssueWorkflow.roles.preparer.systemPrompt);
expect(preparer?.extractPrompt).toBe(solveIssueWorkflow.roles.preparer.extractPrompt);
});
test("hydrated role includes the JSON Schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
const schema = result?.roles.preparer?.schema;
expect(schema).toBeDefined();
expect((schema as Record<string, unknown>)?.type).toBe("object");
});
test("hydrated workflow contains moderator rules matching the definition", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
expect(result?.moderator).toHaveLength(developWorkflow.moderator.length);
expect(result?.moderator[0]).toEqual(developWorkflow.moderator[0]);
});
test("develop workflow round-trip has 5 roles", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const result = loadWorkflow(store, typeHashes, hash);
expect(Object.keys(result?.roles ?? {})).toHaveLength(5);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 4: validate() — CAS nodes pass validation against their schemas
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 4: validate", () => {
test("workflow node is valid against its schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const node = store.get(hash) as CasNode;
expect(validate(store, node)).toBe(true);
});
test("role nodes are valid against their schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
const roleNode = store.get(roleHash) as CasNode;
expect(validate(store, roleNode)).toBe(true);
}
});
test("role-schema nodes are valid against their schema", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
const roleNode = store.get(roleHash) as CasNode;
const schemaHash = (roleNode.payload as Record<string, string>).schema;
const schemaNode = store.get(schemaHash) as CasNode;
expect(validate(store, schemaNode)).toBe(true);
}
});
test("workflow node with wrong type for roles fails validation", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const badHash = await store.put(typeHashes.workflow, {
name: "bad",
description: "bad",
roles: "not-an-object",
moderator: [],
});
const node = store.get(badHash) as CasNode;
expect(validate(store, node)).toBe(false);
});
test("role node missing required field fails validation", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const badHash = await store.put(typeHashes.role, {
name: "bad",
description: "d",
systemPrompt: "s",
});
const node = store.get(badHash) as CasNode;
expect(validate(store, node)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 5: refs() — extracts cas_ref hashes from workflow and role nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 5: refs", () => {
test("workflow node refs() returns one hash per role", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const node = store.get(hash) as CasNode;
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
expect(refs(store, node)).toHaveLength(roleCount);
});
test("role node refs() returns exactly one hash (the schema)", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
const firstRoleHash = Object.values(roles)[0];
const roleNode = store.get(firstRoleHash) as CasNode;
const roleRefs = refs(store, roleNode);
expect(roleRefs).toHaveLength(1);
});
test("role refs() points to the role-schema node", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
const firstRoleHash = Object.values(roles)[0];
const roleNode = store.get(firstRoleHash) as CasNode;
const schemaHash = refs(store, roleNode)[0];
const schemaNode = store.get(schemaHash);
expect(schemaNode).not.toBeNull();
expect(schemaNode?.type).toBe(typeHashes.roleSchema);
});
test("develop workflow node refs() returns one hash per role (5 roles)", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const hash = await registerWorkflow(store, typeHashes, developWorkflow);
const node = store.get(hash) as CasNode;
expect(refs(store, node)).toHaveLength(5);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Step 6: walk() — BFS traversal visits workflow, role, and schema nodes
// ─────────────────────────────────────────────────────────────────────────────
describe("Step 6: walk", () => {
test("walk from workflow hash visits workflow + role + schema nodes", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const visited = new Set<string>();
walk(store, wfHash, (h) => visited.add(h));
// workflow node itself
expect(visited.has(wfHash)).toBe(true);
// all role nodes and their schema nodes should be reachable
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
for (const roleHash of Object.values(roles)) {
expect(visited.has(roleHash)).toBe(true);
const roleNode = store.get(roleHash) as CasNode;
const schemaHash = refs(store, roleNode)[0];
expect(visited.has(schemaHash)).toBe(true);
}
});
test("walk visits all 5 role nodes for develop workflow", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, developWorkflow);
const visited = new Set<string>();
walk(store, wfHash, (h) => visited.add(h));
const wfNode = store.get(wfHash) as CasNode;
const roles = (wfNode.payload as Record<string, unknown>).roles as Record<string, string>;
expect(Object.values(roles).every((rh) => visited.has(rh))).toBe(true);
});
test("walk total node count = 1 workflow + N roles + N schemas", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
const wfHash = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
const visited = new Set<string>();
walk(store, wfHash, (h) => visited.add(h));
// 1 workflow + roleCount roles + roleCount schemas
expect(visited.size).toBe(1 + roleCount + roleCount);
});
test("walk handles two workflows sharing a schema node — visits it only once", async () => {
const store = createMemoryStore();
const typeHashes = await registerWorkflowSchemas(store);
// Register the same workflow twice — second call is idempotent, same hashes
const hash1 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
const hash2 = await registerWorkflow(store, typeHashes, solveIssueWorkflow);
expect(hash1).toBe(hash2);
const visited = new Set<string>();
walk(store, hash1, (h) => visited.add(h));
// Each node should be counted exactly once despite any shared refs
const roleCount = Object.keys(solveIssueWorkflow.roles).length;
expect(visited.size).toBe(1 + roleCount + roleCount);
});
test("walk with unknown starting hash visits nothing", () => {
const store = createMemoryStore();
const visited: string[] = [];
walk(store, "AAAAAAAAAAAAA", (h) => visited.push(h));
expect(visited).toHaveLength(0);
});
});
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@uncaged/workflow-json-def",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.1.0",
"@uncaged/json-cas-workflow": "^0.1.0"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
-19
View File
@@ -1,19 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
/**
* Store an agent instance in CAS.
*
* Writes an `agent` node with `{ package, version, config }` and returns
* the content-addressed hash. Idempotent: the same inputs always produce
* the same hash.
*/
export async function putAgentNode(
store: Store,
typeHashes: WorkflowSchemaHashes,
pkg: string,
version: string,
config: Record<string, unknown>,
): Promise<Hash> {
return store.put(typeHashes.agent, { package: pkg, version, config });
}
@@ -1,2 +0,0 @@
export const START = "__start__" as const;
export const END = "__end__" as const;
@@ -1,284 +0,0 @@
import type { WorkflowInput } from "../types.js";
import { END, START } from "./constants.js";
export const DEVELOP_WORKFLOW_DESCRIPTION =
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
// ── JSONata conditions ────────────────────────────────────────────────────────
/**
* True when the planner aborted due to insufficient information.
* Translates the plannerAborted TypeScript condition to JSONata.
*/
const PLANNER_ABORTED = "$boolean(steps[role='planner'].meta.status = 'aborted')";
/**
* True when all planned phases have been completed by the coder.
*
* Logic:
* - No planned phases → true (nothing to complete)
* - Last phase hash appears in any coder step's completedPhase → true
* - Every phase hash appears in some coder's completedPhase → true (via count check)
*/
const ALL_PHASES_COMPLETE = [
"(",
" $plannerMeta := steps[role='planner'].meta;",
" $phases := $plannerMeta.status = 'planned' ? $plannerMeta.phases : [];",
" $count($phases) = 0 ? true :",
" (",
" $lastHash := $phases[-1].hash;",
" $completedHashes := steps[role='coder'].meta.completedPhase;",
" $lastHash in $completedHashes or",
" $count($phases[$not(hash in $completedHashes)]) = 0",
" )",
")",
].join(" ");
/** True when the most recent reviewer step reported approved. */
const REVIEW_APPROVED = "steps[-1].meta.status = 'approved'";
/** True when the most recent tester step reported passed. */
const TESTS_PASSED = "steps[-1].meta.status = 'passed'";
// ── Workflow definition ───────────────────────────────────────────────────────
export const developWorkflow: WorkflowInput = {
name: "develop",
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: {
planner: {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time. **Abort** if the prompt lacks critical information (e.g. no project/workspace path, ambiguous target repo).
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Prerequisites — check FIRST
The prompt MUST include an **absolute filesystem path** to the project workspace (e.g. \`/home/user/repos/my-project\`). If no workspace path is given and you cannot reliably infer one from context, **abort immediately** with a clear reason explaining what information is missing. Do NOT guess paths.
## Storing phase details — MANDATORY
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
**Do NOT store phase details in any other way** — the CLI is the only supported storage mechanism.
## Phase granularity
Match the number of phases to task complexity:
- Trivial (add a config option, fix a typo, rename): 1 phase
- Small (a new feature touching 2-3 files): 1-2 phases
- Medium (cross-module refactor): 2-3 phases
- Large (new subsystem, architectural change): 3-5 phases
Fewer phases is always better. Each phase must justify its existence — if two phases would be tested together anyway, merge them.
## Output format
After storing all phases via the CLI, output compact JSON only:
{ "status": "planned", "phases": [{ "hash": "<hash-from-cas-put>", "title": "<one-line-summary>" }] }
If aborting:
{ "status": "aborted", "reason": "<what is missing>" }
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases.
## Output rules
Keep your final response **short** — just the JSON with phases. Do NOT paste code snippets, diffs, or implementation details in your response. Phase details are already stored in CAS; your response should only contain the compact phases JSON.`,
extractPrompt:
"Extract the planner result as JSON. Use status='planned' with phases array (hash+title), or status='aborted' with reason.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "phases"],
properties: {
status: { type: "string", enum: ["planned"] },
phases: {
type: "array",
items: {
type: "object",
required: ["hash", "title"],
properties: {
hash: { type: "string" },
title: { type: "string" },
},
},
},
},
},
{
required: ["status", "reason"],
properties: {
status: { type: "string", enum: ["aborted"] },
reason: { type: "string" },
},
},
],
},
},
coder: {
description:
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: `You are a **coder**. Read the thread for the plan and work on the NEXT incomplete phase only.
Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and meta output guide.
## Reading phase details
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
## Completing a phase
Report which phase you completed using the phase **hash** (not the title). If you legitimately finish every remaining phase in this single turn, set completedPhase to the **last** phase hash in the plan (the workflow treats that as full completion). List the files you changed and summarize what you did.
## Output rules
Keep your final response **short** — a brief summary paragraph plus the structured meta output. Do NOT paste diffs, file contents, or code blocks in your response. The actual changes are already on disk; repeating them wastes tokens. Just say what you did and why.`,
extractPrompt:
"Extract the coder result as JSON with fields: completedPhase (hash string), filesChanged (array), summary.",
schema: {
type: "object",
required: ["completedPhase", "filesChanged", "summary"],
properties: {
completedPhase: { type: "string" },
filesChanged: { type: "array", items: { type: "string" } },
summary: { type: "string" },
},
},
},
reviewer: {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
## Review process
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
2. Review the diff against these conventions.
3. For documentation changes, verify that names, paths, and references match the actual codebase.
## Review checklist
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
- **Conventions** — naming, imports, code style per project rules?
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
- **Edge cases** — missing error handling, null checks, boundary conditions?
## Verdict
- **Approve** only if there are zero issues
- **Reject** with specific issues that must be fixed — every issue you find is blocking
Be thorough. A false approve costs more than a false reject.
## Output rules
Keep your final response **short**. Summarize findings in a few bullet points, then output the structured verdict. Do NOT paste the full diff or large code blocks in your response.`,
extractPrompt:
"Extract the reviewer verdict as JSON. Use status='approved', or status='rejected' with issues array.",
schema: {
type: "object",
oneOf: [
{
required: ["status"],
properties: {
status: { type: "string", enum: ["approved"] },
},
},
{
required: ["status", "issues"],
properties: {
status: { type: "string", enum: ["rejected"] },
issues: { type: "array", items: { type: "string" } },
},
},
],
},
},
tester: {
description: "Runs test, build, and lint commands and reports pass or fail with details.",
systemPrompt: `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.
## Output rules
Keep your final response **short**. Report pass/fail with a brief summary of failures (if any). Do NOT paste full test output or build logs — just the key error lines.`,
extractPrompt:
"Extract the tester result as JSON. Use status='passed' or status='failed', both with details string.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "details"],
properties: {
status: { type: "string", enum: ["passed"] },
details: { type: "string" },
},
},
{
required: ["status", "details"],
properties: {
status: { type: "string", enum: ["failed"] },
details: { type: "string" },
},
},
],
},
},
committer: {
description: "Creates a branch and commits changes.",
systemPrompt:
"You are the git committer. Create a branch and commit the changes. Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable. Do not attempt to fix failures yourself.",
extractPrompt:
"Extract the committer result as JSON. Use status='committed' with branch+commitSha, status='recoverable' with error+logRef, or status='unrecoverable' with error+logRef.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "branch", "commitSha"],
properties: {
status: { type: "string", enum: ["committed"] },
branch: { type: "string" },
commitSha: { type: "string" },
},
},
{
required: ["status", "error", "logRef"],
properties: {
status: { type: "string", enum: ["recoverable"] },
error: { type: "string" },
logRef: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
{
required: ["status", "error", "logRef"],
properties: {
status: { type: "string", enum: ["unrecoverable"] },
error: { type: "string" },
logRef: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
],
},
},
},
moderator: [
{ from: START, to: "planner", when: null },
{ from: "planner", to: END, when: PLANNER_ABORTED },
{ from: "planner", to: "coder", when: null },
{ from: "coder", to: "reviewer", when: ALL_PHASES_COMPLETE },
{ from: "coder", to: "coder", when: null },
{ from: "reviewer", to: "tester", when: REVIEW_APPROVED },
{ from: "reviewer", to: "coder", when: null },
{ from: "tester", to: "committer", when: TESTS_PASSED },
{ from: "tester", to: "coder", when: null },
{ from: "committer", to: END, when: null },
],
};
@@ -1,3 +0,0 @@
export { END, START } from "./constants.js";
export { DEVELOP_WORKFLOW_DESCRIPTION, developWorkflow } from "./develop.js";
export { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueWorkflow } from "./solve-issue.js";
@@ -1,128 +0,0 @@
import type { WorkflowInput } from "../types.js";
import { END, START } from "./constants.js";
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
"Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter).";
export const solveIssueWorkflow: WorkflowInput = {
name: "solve-issue",
description: SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
roles: {
preparer: {
description:
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
systemPrompt: `You are a **preparer** for a software task. Your job is to locate (or clone) the target repository locally, ensure it is up to date, and gather project context before work begins.
## Responsibilities
1. Parse the issue/task prompt to identify the target repository (URL, org/repo, or name).
2. Search for an existing local clone in these locations (in order):
- ~/Code/<repo-name>/
- ~/repos/<repo-name>/
- ~/Code/<org>/<repo-name>/
- ~/repos/<org>/<repo-name>/
3. If not found locally, \`git clone\` it into ~/repos/<repo-name>/.
4. \`git checkout main && git pull\` (or the default branch) to ensure latest.
5. Read project conventions: \`CLAUDE.md\`, \`CONTRIBUTING.md\`, \`.cursor/rules/*.mdc\`, \`CONVENTIONS.md\`.
6. Detect toolchain: package manager, test runner, linter, build system.
## Output
Report your findings as structured data:
- **repoPath**: absolute path to the local repo
- **defaultBranch**: the default branch name (e.g. "main")
- **conventions**: a summary of project conventions found, or null if none
- **toolchain**: detected commands for packageManager, testCommand, lintCommand, buildCommand (null if not detected)`,
extractPrompt:
"Extract the structured repo preparation result as JSON with fields: repoPath, defaultBranch, conventions, toolchain.",
schema: {
type: "object",
required: ["repoPath", "defaultBranch", "conventions", "toolchain"],
properties: {
repoPath: { type: "string" },
defaultBranch: { type: "string" },
conventions: { anyOf: [{ type: "string" }, { type: "null" }] },
toolchain: {
type: "object",
required: ["packageManager", "testCommand", "lintCommand", "buildCommand"],
properties: {
packageManager: { anyOf: [{ type: "string" }, { type: "null" }] },
testCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
lintCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
buildCommand: { anyOf: [{ type: "string" }, { type: "null" }] },
},
},
},
},
},
developer: {
description:
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
systemPrompt: `You are the **developer**. You delegate the implementation work to the \`develop\` workflow.
The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread.
Pass through the task and let the child workflow do the work.`,
extractPrompt:
"Extract the developer result as JSON with fields: branch, commitSha, filesChanged (array), summary.",
schema: {
type: "object",
required: ["branch", "commitSha", "filesChanged", "summary"],
properties: {
branch: { type: "string" },
commitSha: { type: "string" },
filesChanged: { type: "array", items: { type: "string" } },
summary: { type: "string" },
},
},
},
submitter: {
description: "Pushes the developer's branch to the remote and opens a pull request.",
systemPrompt: `You are the **submitter**. Your job is to push the work branch to the remote and open a pull request.
## Inputs
Read the thread for context:
- The **preparer**'s output gives you the absolute repo path and the default branch (and remote URL by inspecting the repo).
- The **developer**'s output gives you the branch name that was committed and a list of files changed plus a summary of the work.
## Procedure
1. \`cd\` into the repo path from the preparer's output.
2. Push the developer's branch to the remote: \`git push -u origin <branch>\`.
3. Open a pull request (e.g. via \`gh pr create\`) targeting the default branch. The PR title should be short and describe the change. The PR description should summarize what changed (drawing from the developer's summary and filesChanged) and reference the original issue/task if applicable.
4. Report the resulting PR URL.
On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`,
extractPrompt:
"Extract the submitter result as JSON. Use status='submitted' with prUrl, or status='failed' with error.",
schema: {
type: "object",
oneOf: [
{
required: ["status", "prUrl"],
properties: {
status: { type: "string", enum: ["submitted"] },
prUrl: { type: "string" },
},
},
{
required: ["status", "error"],
properties: {
status: { type: "string", enum: ["failed"] },
error: { type: "string" },
},
},
],
},
},
},
moderator: [
{ from: START, to: "preparer", when: null },
{ from: "preparer", to: "developer", when: null },
{ from: "developer", to: "submitter", when: null },
{ from: "submitter", to: END, when: null },
],
};
-12
View File
@@ -1,12 +0,0 @@
export { putAgentNode } from "./agent.js";
export {
DEVELOP_WORKFLOW_DESCRIPTION,
developWorkflow,
END,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
START,
solveIssueWorkflow,
} from "./definitions/index.js";
export { loadWorkflow } from "./load.js";
export { registerWorkflow } from "./register.js";
export type { HydratedRole, HydratedWorkflow, RoleInput, WorkflowInput } from "./types.js";
-56
View File
@@ -1,56 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type {
RolePayload,
WorkflowPayload,
WorkflowSchemaHashes,
} from "@uncaged/json-cas-workflow";
import type { HydratedRole, HydratedWorkflow } from "./types.js";
/**
* Load a workflow from CAS by its hash.
*
* Reads the workflow node, then for each role ref reads the role node and
* its associated role-schema node. Returns a fully hydrated workflow structure.
*
* Returns null if the workflow node cannot be found.
*/
export function loadWorkflow(
store: Store,
_typeHashes: WorkflowSchemaHashes,
workflowHash: Hash,
): HydratedWorkflow | null {
const workflowNode = store.get(workflowHash);
if (workflowNode === null) {
return null;
}
const wf = workflowNode.payload as WorkflowPayload;
const roles: Record<string, HydratedRole> = {};
for (const [roleName, roleHash] of Object.entries(wf.roles)) {
const roleNode = store.get(roleHash);
if (roleNode === null) {
continue;
}
const rolePayload = roleNode.payload as RolePayload;
const schemaNode = store.get(rolePayload.schema);
const schema = schemaNode !== null ? (schemaNode.payload as Record<string, unknown>) : {};
roles[roleName] = {
name: rolePayload.name,
description: rolePayload.description,
systemPrompt: rolePayload.systemPrompt,
extractPrompt: rolePayload.extractPrompt,
schema,
};
}
return {
name: wf.name,
description: wf.description,
roles,
moderator: wf.moderator,
};
}
@@ -1,43 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import type { WorkflowSchemaHashes } from "@uncaged/json-cas-workflow";
import type { WorkflowInput } from "./types.js";
/**
* Store a workflow definition in CAS.
*
* For each role:
* 1. Store the role's JSON Schema as a role-schema node → schemaHash
* 2. Store the role as a role node referencing schemaHash → roleHash
*
* Then store the workflow node referencing all role hashes and moderator rules.
* Returns the workflow CAS hash.
*/
export async function registerWorkflow(
store: Store,
typeHashes: WorkflowSchemaHashes,
workflowDef: WorkflowInput,
): Promise<Hash> {
const roleHashes: Record<string, Hash> = {};
for (const [roleName, roleInput] of Object.entries(workflowDef.roles)) {
const schemaHash = await store.put(typeHashes.roleSchema, roleInput.schema);
const roleHash = await store.put(typeHashes.role, {
name: roleName,
description: roleInput.description,
systemPrompt: roleInput.systemPrompt,
extractPrompt: roleInput.extractPrompt,
schema: schemaHash,
});
roleHashes[roleName] = roleHash;
}
const workflowHash = await store.put(typeHashes.workflow, {
name: workflowDef.name,
description: workflowDef.description,
roles: roleHashes,
moderator: workflowDef.moderator,
});
return workflowHash;
}
-35
View File
@@ -1,35 +0,0 @@
import type { JSONSchema } from "@uncaged/json-cas";
import type { WorkflowTransition } from "@uncaged/json-cas-workflow";
// ── Input types (high-level workflow definition) ──────────────────────────────
export type RoleInput = {
description: string;
systemPrompt: string;
extractPrompt: string;
schema: JSONSchema;
};
export type WorkflowInput = {
name: string;
description: string;
roles: Record<string, RoleInput>;
moderator: WorkflowTransition[];
};
// ── Output types (hydrated workflow from CAS) ─────────────────────────────────
export type HydratedRole = {
name: string;
description: string;
systemPrompt: string;
extractPrompt: string;
schema: JSONSchema;
};
export type HydratedWorkflow = {
name: string;
description: string;
roles: Record<string, HydratedRole>;
moderator: WorkflowTransition[];
};
-22
View File
@@ -1,22 +0,0 @@
{
"references": [],
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}
@@ -1,284 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { ModeratorRule } from "../src/jsonata-moderator.js";
import { evaluateModerator } from "../src/jsonata-moderator.js";
import type { ModeratorContext, StartStep } from "../src/types.js";
import { END, START } from "../src/types.js";
// ── Helpers ─────────────────────────────────────────────────────────
const BASE_START: StartStep = {
role: START,
content: "test task",
meta: {},
timestamp: 1000,
parentState: null,
};
function makeCtx(
steps: Array<{ role: string; meta: Record<string, unknown> }>,
overrides: Partial<ModeratorContext> = {},
): ModeratorContext {
return {
threadId: "01JTESTTHREADID000",
depth: 0,
bundleHash: "TESTHASH00001",
start: BASE_START,
steps: steps.map((s, i) => ({
...s,
contentHash: `hash-${i}`,
refs: [],
timestamp: 2000 + i,
})) as ModeratorContext["steps"],
...overrides,
};
}
// ── Step 1: FALLBACK rule (when=null) always matches ────────────────
describe("Step 1: FALLBACK rule", () => {
test("when=null always matches → returns rule.to", async () => {
const rules: ModeratorRule[] = [{ from: START, to: "planner", when: null }];
const result = await evaluateModerator(rules, makeCtx([]));
expect(result).toBe("planner");
});
});
// ── Step 2: No matching rules → __end__ ─────────────────────────────
describe("Step 2: no matching rules", () => {
test("empty steps, no rule for __start__ → __end__", async () => {
const rules: ModeratorRule[] = [{ from: "planner", to: "coder", when: null }];
const result = await evaluateModerator(rules, makeCtx([]));
expect(result).toBe(END);
});
test("current state has no matching from rules → __end__", async () => {
const rules: ModeratorRule[] = [{ from: START, to: "planner", when: null }];
const result = await evaluateModerator(rules, makeCtx([{ role: "planner", meta: {} }]));
expect(result).toBe(END);
});
});
// ── Step 3: JSONata condition — truthy → match ────────────────────
describe("Step 3: JSONata condition evaluation", () => {
test("truthy expression matches and returns rule.to", async () => {
const rules: ModeratorRule[] = [
{
from: "planner",
to: END,
when: "steps[role='planner'].meta.status = 'aborted'",
},
{ from: "planner", to: "coder", when: null },
];
const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]);
expect(await evaluateModerator(rules, ctx)).toBe(END);
});
test("falsy expression skips to next rule (FALLBACK)", async () => {
const rules: ModeratorRule[] = [
{
from: "planner",
to: END,
when: "steps[role='planner'].meta.status = 'aborted'",
},
{ from: "planner", to: "coder", when: null },
];
const ctx = makeCtx([{ role: "planner", meta: { status: "planned" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
});
// ── Step 4: plannerAborted condition ─────────────────────────────
describe("Step 4: plannerAborted", () => {
const plannerAbortedRules: ModeratorRule[] = [
{
from: "planner",
to: END,
when: "steps[role='planner'].meta.status = 'aborted'",
},
{ from: "planner", to: "coder", when: null },
];
test("planner aborted → __end__", async () => {
const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]);
expect(await evaluateModerator(plannerAbortedRules, ctx)).toBe(END);
});
test("planner planned → coder", async () => {
const ctx = makeCtx([{ role: "planner", meta: { status: "planned", phases: [] } }]);
expect(await evaluateModerator(plannerAbortedRules, ctx)).toBe("coder");
});
});
// ── Step 5: reviewApproved / testsPassed (negative index) ────────
describe("Step 5: last-step conditions", () => {
const rules: ModeratorRule[] = [
{
from: "reviewer",
to: "tester",
when: "steps[-1].meta.status = 'approved'",
},
{ from: "reviewer", to: "coder", when: null },
{
from: "tester",
to: "committer",
when: "steps[-1].meta.status = 'passed'",
},
{ from: "tester", to: "coder", when: null },
];
test("reviewer approved → tester", async () => {
const ctx = makeCtx([{ role: "reviewer", meta: { status: "approved" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("tester");
});
test("reviewer changes-requested → coder", async () => {
const ctx = makeCtx([{ role: "reviewer", meta: { status: "changes-requested" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
test("tester passed → committer", async () => {
const ctx = makeCtx([{ role: "tester", meta: { status: "passed" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("committer");
});
test("tester failed → coder", async () => {
const ctx = makeCtx([{ role: "tester", meta: { status: "failed" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
});
// ── Step 6: allPhasesComplete condition ──────────────────────────
describe("Step 6: allPhasesComplete", () => {
// The JSONata expression checks if all planned phase hashes appear in coder completedPhase values.
// Approximation using $count approach:
// Use JSONata 'in' operator inside $filter to check if each planned phase hash
// appears in the coder completedPhase values.
const allPhasesExpr =
"$count(steps[role='planner'].meta.phases) = 0 or $count(steps[role='planner'].meta.phases ~> $filter(function($p) { $p.hash in steps[role='coder'].meta.completedPhase })) >= $count(steps[role='planner'].meta.phases)";
const rules: ModeratorRule[] = [
{ from: "coder", to: "reviewer", when: allPhasesExpr },
{ from: "coder", to: "coder", when: null },
];
test("no phases (empty array) → all complete → reviewer", async () => {
const ctx = makeCtx([
{ role: "planner", meta: { status: "planned", phases: [] } },
{ role: "coder", meta: { completedPhase: "PHASE001" } },
]);
expect(await evaluateModerator(rules, ctx)).toBe("reviewer");
});
test("all phases completed → reviewer", async () => {
const ctx = makeCtx([
{
role: "planner",
meta: {
status: "planned",
phases: [{ hash: "PHASE001" }, { hash: "PHASE002" }],
},
},
{ role: "coder", meta: { completedPhase: "PHASE001" } },
{ role: "coder", meta: { completedPhase: "PHASE002" } },
]);
expect(await evaluateModerator(rules, ctx)).toBe("reviewer");
});
test("not all phases completed → coder (loop)", async () => {
const ctx = makeCtx([
{
role: "planner",
meta: {
status: "planned",
phases: [{ hash: "PHASE001" }, { hash: "PHASE002" }],
},
},
{ role: "coder", meta: { completedPhase: "PHASE001" } },
]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
});
// ── Step 7: Full develop workflow routing table ───────────────────
describe("Step 7: full develop workflow routing", () => {
const allPhasesExpr =
"$count(steps[role='planner'].meta.phases) = 0 or $count(steps[role='planner'].meta.phases ~> $filter(function($p) { $p.hash in steps[role='coder'].meta.completedPhase })) >= $count(steps[role='planner'].meta.phases)";
const rules: ModeratorRule[] = [
{ from: START, to: "planner", when: null },
{ from: "planner", to: END, when: "steps[role='planner'].meta.status = 'aborted'" },
{ from: "planner", to: "coder", when: null },
{ from: "coder", to: "reviewer", when: allPhasesExpr },
{ from: "coder", to: "coder", when: null },
{ from: "reviewer", to: "tester", when: "steps[-1].meta.status = 'approved'" },
{ from: "reviewer", to: "coder", when: null },
{ from: "tester", to: "committer", when: "steps[-1].meta.status = 'passed'" },
{ from: "tester", to: "coder", when: null },
{ from: "committer", to: END, when: null },
];
test("initial state → planner", async () => {
expect(await evaluateModerator(rules, makeCtx([]))).toBe("planner");
});
test("planner planned, no phases → coder", async () => {
const ctx = makeCtx([{ role: "planner", meta: { status: "planned", phases: [] } }]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
test("planner aborted → __end__", async () => {
const ctx = makeCtx([{ role: "planner", meta: { status: "aborted" } }]);
expect(await evaluateModerator(rules, ctx)).toBe(END);
});
test("coder completed single phase → reviewer", async () => {
const ctx = makeCtx([
{ role: "planner", meta: { status: "planned", phases: [{ hash: "PH1" }] } },
{ role: "coder", meta: { completedPhase: "PH1" } },
]);
expect(await evaluateModerator(rules, ctx)).toBe("reviewer");
});
test("coder incomplete phases → coder", async () => {
const ctx = makeCtx([
{
role: "planner",
meta: { status: "planned", phases: [{ hash: "PH1" }, { hash: "PH2" }] },
},
{ role: "coder", meta: { completedPhase: "PH1" } },
]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
test("reviewer approved → tester", async () => {
const ctx = makeCtx([{ role: "reviewer", meta: { status: "approved" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("tester");
});
test("reviewer rejected → coder", async () => {
const ctx = makeCtx([{ role: "reviewer", meta: { status: "changes-requested" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
test("tester passed → committer", async () => {
const ctx = makeCtx([{ role: "tester", meta: { status: "passed" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("committer");
});
test("tester failed → coder", async () => {
const ctx = makeCtx([{ role: "tester", meta: { status: "failed" } }]);
expect(await evaluateModerator(rules, ctx)).toBe("coder");
});
test("committer done → __end__", async () => {
const ctx = makeCtx([{ role: "committer", meta: {} }]);
expect(await evaluateModerator(rules, ctx)).toBe(END);
});
});
-3
View File
@@ -28,8 +28,5 @@
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"jsonata": "^2.2.0"
}
}
-6
View File
@@ -23,7 +23,6 @@ export type {
ModeratorContext,
ModeratorTable,
ModeratorTransition,
PackageDescriptor,
ProviderConfig,
ResolvedModel,
Result,
@@ -55,8 +54,3 @@ export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
// ── JSONata moderator ──────────────────────────────────────────────
export type { ModeratorRule } from "./jsonata-moderator.js";
export { evaluateModerator } from "./jsonata-moderator.js";
@@ -1,66 +0,0 @@
import jsonata from "jsonata";
import type { ModeratorContext, RoleMeta, StartStep } from "./types.js";
import { END, START } from "./types.js";
// ── Types ───────────────────────────────────────────────────────────
export type ModeratorRule = {
from: string;
to: string;
when: string | null;
};
type JsonataContext = {
threadId: string;
depth: number;
start: StartStep;
steps: ReadonlyArray<{ role: string; meta: Record<string, unknown> }>;
};
// ── Evaluator ───────────────────────────────────────────────────────
/**
* Evaluate a JSONata-based moderator rule set against the given thread context.
* Returns the next role name or '__end__'.
*/
export async function evaluateModerator(
rules: ReadonlyArray<ModeratorRule>,
context: ModeratorContext,
): Promise<string> {
const lastStep = context.steps.length > 0 ? context.steps[context.steps.length - 1] : null;
const currentState: string = lastStep ? lastStep.role : START;
const matching = rules.filter((r) => r.from === currentState);
const jsonataCtx: JsonataContext = {
threadId: context.threadId,
depth: context.depth,
start: context.start,
steps: context.steps as ReadonlyArray<{ role: string; meta: Record<string, unknown> }>,
};
for (const rule of matching) {
if (rule.when === null) {
return rule.to;
}
const expr = jsonata(rule.when);
const result = await expr.evaluate(jsonataCtx);
if (result) {
return rule.to;
}
}
return END;
}
// ── Context helper ──────────────────────────────────────────────────
/** Build a ModeratorContext from its constituent parts (convenience for tests / callers). */
export function makeModeratorContext<M extends RoleMeta>(
ctx: ModeratorContext<M>,
): ModeratorContext<M> {
return ctx;
}
+6 -22
View File
@@ -130,26 +130,6 @@ export type WorkflowConfig = {
models: Record<string, string>;
};
// ── Package Descriptor ────────────────────────────────────────────────
/**
* Static metadata describing a workflow agent npm package.
* Stored alongside the CAS agent node to document what an agent instance is.
*/
export type PackageDescriptor = {
/** The npm package name, e.g. `@uncaged/workflow-agent-cursor`. */
name: string;
/** Semver version of the package at the time the descriptor was written. */
version: string;
/** Human-readable capability tags, e.g. `["cursor-cli", "workspace-agent"]`. */
capabilities: string[];
/**
* JSON Schema that describes the serializable config stored in the CAS
* agent node's `config` field.
*/
configSchema: Record<string, unknown>;
};
// ── Functions ──────────────────────────────────────────────────────
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
@@ -174,9 +154,12 @@ export type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
/**
* Core agent function. Input is always {@link ThreadContext}, output is always string.
* `Opt` captures agent-specific structured options (required second argument).
* `Opt` captures agent-specific structured options.
* Agents with no extra options use `AgentFn` (Opt defaults to void).
*/
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
export type AgentFn<Opt = void> = Opt extends void
? (ctx: ThreadContext) => Promise<string>
: (ctx: ThreadContext, options: Opt) => Promise<string>;
export type AdapterBinding = {
adapter: AdapterFn;
@@ -199,6 +182,7 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
schema: z.ZodType<Meta>;
extractRefs: ((meta: Meta) => string[]) | null;
};
export type Moderator<M extends RoleMeta> = (
@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test";
import {
END,
START,
type ModeratorTable,
type WorkflowDefinition,
} from "@uncaged/workflow-protocol";
import * as z from "zod/v4";
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
const phaseSchema = z.object({
hash: z.string().meta({ "x-cas-ref": true }),
title: z.string(),
});
type TestMeta = {
planner: { phases: Array<{ hash: string; title: string }>; label: string };
};
const testTable: ModeratorTable<TestMeta> = {
[START]: [{ condition: "FALLBACK", role: "planner" }],
planner: [{ condition: "FALLBACK", role: END }],
};
describe("buildDescriptor", () => {
test("preserves x-cas-ref in role JSON Schema", () => {
const def: WorkflowDefinition<TestMeta> = {
description: "test workflow",
roles: {
planner: {
description: "plans work",
systemPrompt: "plan",
schema: z.object({
phases: z.array(phaseSchema),
label: z.string(),
}),
},
},
table: testTable,
};
const descriptor = buildDescriptor(def);
const props = (descriptor.roles.planner.schema as { properties: Record<string, unknown> })
.properties;
const phaseProps = (
(props.phases as { items: { properties: Record<string, unknown> } }).items
).properties;
expect((phaseProps.hash as Record<string, unknown>)["x-cas-ref"]).toBe(true);
expect((phaseProps.title as Record<string, unknown>)["x-cas-ref"]).toBeUndefined();
expect((props.label as Record<string, unknown>)["x-cas-ref"]).toBeUndefined();
});
});
-3
View File
@@ -29,9 +29,6 @@
"zod": "^4.0.0",
"typescript": "^5.8.3"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
@@ -60,30 +60,6 @@ function validateDescriptorGraph(graphRaw: unknown): Result<WorkflowGraph, strin
return ok({ edges });
}
function validateDescriptorRole(
roleName: string,
specUnknown: unknown,
): Result<WorkflowRoleDescriptor, string> {
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
return err(`descriptor.roles.${roleName} must be a non-array object`);
}
const spec = specUnknown as Record<string, unknown>;
const roleDesc = spec.description;
if (typeof roleDesc !== "string") {
return err(`descriptor.roles.${roleName}.description must be a string`);
}
const schema = spec.schema;
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
}
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
return ok({
description: roleDesc,
systemPrompt,
schema: schema as WorkflowRoleSchema,
});
}
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return err("descriptor must be a non-array object");
@@ -100,11 +76,24 @@ export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescr
const roles: Record<string, WorkflowRoleDescriptor> = {};
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
const roleResult = validateDescriptorRole(roleName, specUnknown);
if (!roleResult.ok) {
return roleResult;
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
return err(`descriptor.roles.${roleName} must be a non-array object`);
}
roles[roleName] = roleResult.value;
const spec = specUnknown as Record<string, unknown>;
const roleDesc = spec.description;
if (typeof roleDesc !== "string") {
return err(`descriptor.roles.${roleName}.description must be a string`);
}
const schema = spec.schema;
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
}
const systemPrompt = typeof spec.systemPrompt === "string" ? spec.systemPrompt : "";
roles[roleName] = {
description: roleDesc,
systemPrompt,
schema: schema as WorkflowRoleSchema,
};
}
const graphResult = validateDescriptorGraph(root.graph);
@@ -1,114 +0,0 @@
import { describe, expect, test } from "bun:test";
import * as z from "zod/v4";
import { collectCasRefs } from "../src/collect-cas-refs.js";
const phaseSchema = z.object({
hash: z.string().meta({ 'x-cas-ref': true }),
title: z.string(),
});
const plannerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("planned"),
phases: z.array(phaseSchema),
}),
z.object({
status: z.literal("aborted"),
reason: z.string(),
}),
]);
describe("collectCasRefs", () => {
test("1. flat field with x-cas-ref annotation", () => {
const schema = z.object({
completedPhase: z.string().meta({ 'x-cas-ref': true }),
});
expect(collectCasRefs(schema, { completedPhase: "BHAAAAAAAAAAA" })).toEqual(["BHAAAAAAAAAAA"]);
});
test("2. plain string without annotation is ignored", () => {
const schema = z.object({
summary: z.string(),
completedPhase: z.string().meta({ 'x-cas-ref': true }),
});
expect(
collectCasRefs(schema, {
summary: "done",
completedPhase: "BHBBBBBBBBBBB",
}),
).toEqual(["BHBBBBBBBBBBB"]);
});
test("3. nested array of objects collects each annotated hash", () => {
const schema = z.object({
phases: z.array(phaseSchema),
});
expect(
collectCasRefs(schema, {
phases: [
{ hash: "BH11111111111", title: "setup" },
{ hash: "BH22222222222", title: "impl" },
],
}),
).toEqual(["BH11111111111", "BH22222222222"]);
});
test("4. discriminatedUnion — planner planned branch", () => {
expect(
collectCasRefs(plannerMetaSchema, {
status: "planned",
phases: [
{ hash: "BH33333333333", title: "a" },
{ hash: "BH44444444444", title: "b" },
],
}),
).toEqual(["BH33333333333", "BH44444444444"]);
});
test("4b. discriminatedUnion — planner aborted branch", () => {
expect(
collectCasRefs(plannerMetaSchema, {
status: "aborted",
reason: "missing workspace",
}),
).toEqual([]);
});
test("5. null and undefined annotated fields are skipped", () => {
const schema = z.object({
ref: z.string().meta({ 'x-cas-ref': true }).nullable(),
optionalRef: z.string().meta({ 'x-cas-ref': true }).optional(),
});
expect(collectCasRefs(schema, { ref: null, optionalRef: undefined })).toEqual([]);
expect(collectCasRefs(schema, { ref: "BH55555555555", optionalRef: undefined })).toEqual([
"BH55555555555",
]);
});
test("6. mixed annotated and plain fields at multiple levels", () => {
const schema = z.object({
label: z.string(),
phase: z.object({
hash: z.string().meta({ 'x-cas-ref': true }),
title: z.string(),
}),
tags: z.array(z.string()),
});
expect(
collectCasRefs(schema, {
label: "coder",
phase: { hash: "BH66666666666", title: "fix" },
tags: ["a", "b"],
}),
).toEqual(["BH66666666666"]);
});
test("7. empty phases array yields no refs", () => {
expect(
collectCasRefs(plannerMetaSchema, {
status: "planned",
phases: [],
}),
).toEqual([]);
});
});
@@ -31,6 +31,7 @@ const roles: WorkflowDefinition<GreetMeta>["roles"] = {
systemPrompt:
"You are a friendly greeter. Given a user prompt, produce a warm greeting. Respond in valid JSON with keys: greeting (string), language (string).",
schema: greeterSchema,
extractRefs: null,
},
};
@@ -1,122 +0,0 @@
import * as z from "zod/v4";
type ZodSchema = z.ZodType;
type DefPipeIn = { in: ZodSchema };
function hasCasRef(schema: ZodSchema): boolean {
const meta = z.globalRegistry.get(schema);
return meta !== undefined && meta["x-cas-ref"] === true;
}
function walkOptional(schema: z.ZodOptional<ZodSchema>, data: unknown): string[] {
if (data === undefined) {
return [];
}
return walkCasRefs(schema.unwrap(), data);
}
function walkNullable(schema: z.ZodNullable<ZodSchema>, data: unknown): string[] {
if (data === null) {
return [];
}
return walkCasRefs(schema.unwrap(), data);
}
function walkDefault(schema: z.ZodDefault<ZodSchema>, data: unknown): string[] {
return walkCasRefs(schema.unwrap(), data);
}
function walkPrefault(schema: z.ZodPrefault<ZodSchema>, data: unknown): string[] {
return walkCasRefs(schema.unwrap(), data);
}
function walkCatch(schema: z.ZodCatch<ZodSchema>, data: unknown): string[] {
return walkCasRefs(schema.unwrap(), data);
}
function walkReadonly(schema: z.ZodReadonly<ZodSchema>, data: unknown): string[] {
return walkCasRefs(schema.unwrap(), data);
}
function walkPipe(def: DefPipeIn, data: unknown): string[] {
return walkCasRefs(def.in, data);
}
function walkString(schema: ZodSchema, data: unknown): string[] {
if (hasCasRef(schema) && typeof data === "string") {
return [data];
}
return [];
}
function walkObject(schema: z.ZodObject<z.ZodRawShape>, data: unknown): string[] {
if (typeof data !== "object" || data === null || Array.isArray(data)) {
return [];
}
const record = data as Record<string, unknown>;
const shape = schema.shape;
const refs: string[] = [];
for (const [key, fieldSchema] of Object.entries(shape)) {
refs.push(...walkCasRefs(fieldSchema as ZodSchema, record[key]));
}
return refs;
}
function walkArray(schema: z.ZodArray<ZodSchema>, data: unknown): string[] {
if (!Array.isArray(data)) {
return [];
}
const element = schema.element;
const refs: string[] = [];
for (const item of data) {
refs.push(...walkCasRefs(element, item));
}
return refs;
}
function walkUnion(schema: z.ZodUnion<readonly ZodSchema[]>, data: unknown): string[] {
for (const option of schema.options) {
const parsed = option.safeParse(data);
if (parsed.success) {
return walkCasRefs(option, data);
}
}
return [];
}
function walkCasRefs(schema: ZodSchema, data: unknown): string[] {
const def = schema.def;
switch (def.type) {
case "optional":
return walkOptional(schema as z.ZodOptional<ZodSchema>, data);
case "nullable":
return walkNullable(schema as z.ZodNullable<ZodSchema>, data);
case "default":
return walkDefault(schema as z.ZodDefault<ZodSchema>, data);
case "prefault":
return walkPrefault(schema as z.ZodPrefault<ZodSchema>, data);
case "catch":
return walkCatch(schema as z.ZodCatch<ZodSchema>, data);
case "readonly":
return walkReadonly(schema as z.ZodReadonly<ZodSchema>, data);
case "pipe":
return walkPipe(def as unknown as DefPipeIn, data);
case "string":
return walkString(schema, data);
case "object":
return walkObject(schema as z.ZodObject<z.ZodRawShape>, data);
case "array":
return walkArray(schema as z.ZodArray<ZodSchema>, data);
case "union":
return walkUnion(schema as z.ZodUnion<readonly ZodSchema[]>, data);
default:
return [];
}
}
/** Collect CAS content hashes from meta using `x-cas-ref` annotations on the Zod schema. */
export function collectCasRefs(schema: ZodSchema, data: unknown): string[] {
return walkCasRefs(schema, data);
}
@@ -2,13 +2,13 @@ import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import type * as z from "zod/v4";
import { collectCasRefs } from "./collect-cas-refs.js";
import {
type AdapterBinding,
type AdapterFn,
type AdvanceOutcome,
END,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
@@ -26,6 +26,17 @@ function isRoleNext<M extends RoleMeta>(
return next !== END;
}
function resolveExtractedRefs(
roleDef: RoleDefinition<Record<string, unknown>>,
meta: unknown,
): string[] {
const extractRefsFn = roleDef.extractRefs;
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
return [];
}
return extractRefsFn(meta as Record<string, unknown>);
}
function _mergeUniqueHashes(a: readonly string[], b: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
@@ -79,7 +90,10 @@ async function advanceOneRound<M extends RoleMeta>(
const result = await roleFn(modCtx as unknown as ThreadContext, runtime);
const meta = result.meta;
const refsFromMeta = collectCasRefs(roleDef.schema as z.ZodType, meta);
const refsFromMeta = resolveExtractedRefs(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
meta,
);
const contentPayload = JSON.stringify(meta);
const contentHash = await putContentNodeWithRefs(runtime.cas, contentPayload, refsFromMeta);
+3 -5
View File
@@ -1,3 +1,6 @@
export { buildThreadContext } from "./build-context.js";
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
export type {
AdapterFn,
AgentContext,
@@ -10,7 +13,6 @@ export type {
ModeratorCondition,
ModeratorContext,
ModeratorTable,
PackageDescriptor,
Result,
RoleDefinition,
RoleFn,
@@ -26,7 +28,3 @@ export type {
WorkflowRuntime,
} from "@uncaged/workflow-protocol";
export { END, START } from "@uncaged/workflow-protocol";
export { buildThreadContext } from "./build-context.js";
export { collectCasRefs } from "./collect-cas-refs.js";
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
@@ -4,7 +4,6 @@ import * as z from "zod/v4";
export const coderMetaSchema = z.object({
completedPhase: z
.string()
.meta({ casRef: true })
.describe(
"The planner phase hash finished this round. If multiple phases were completed, use the last finished phase hash.",
),
@@ -37,4 +36,5 @@ export const coderRole: RoleDefinition<CoderMeta> = {
"Implements the next incomplete planner phase and reports structured completion metadata.",
systemPrompt: CODER_SYSTEM,
schema: coderMetaSchema,
extractRefs: (meta) => [meta.completedPhase],
};
@@ -29,4 +29,5 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
description: "Creates a branch and commits changes.",
systemPrompt: COMMITTER_SYSTEM,
schema: committerMetaSchema,
extractRefs: null,
};
@@ -2,7 +2,7 @@ import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const phaseSchema = z.object({
hash: z.string().meta({ casRef: true }),
hash: z.string(),
title: z.string(),
});
@@ -63,4 +63,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
description: "Breaks the task into sequential phases for the coder.",
systemPrompt: PLANNER_SYSTEM,
schema: plannerMetaSchema,
extractRefs: (meta) => (meta.status === "planned" ? meta.phases.map((p) => p.hash) : []),
};
@@ -42,4 +42,5 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",
systemPrompt: REVIEWER_SYSTEM,
schema: reviewerMetaSchema,
extractRefs: null,
};
@@ -24,4 +24,5 @@ export const testerRole: RoleDefinition<TesterMeta> = {
description: "Runs test, build, and lint commands and reports pass or fail with details.",
systemPrompt: TESTER_SYSTEM,
schema: testerMetaSchema,
extractRefs: null,
};
@@ -31,4 +31,8 @@ describe("submitterRole", () => {
expect(submitterRole.systemPrompt).toContain("submitter");
expect(submitterRole.systemPrompt).toContain("pull request");
});
test("has no refs extractor", () => {
expect(submitterRole.extractRefs).toBeNull();
});
});
@@ -21,4 +21,5 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
"Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.",
systemPrompt: DEVELOPER_SYSTEM,
schema: developerMetaSchema,
extractRefs: () => [],
};
@@ -45,4 +45,5 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
"Locates or clones the target repository, ensures it is up to date, and gathers project context (conventions, toolchain).",
systemPrompt: PREPARER_SYSTEM,
schema: preparerMetaSchema,
extractRefs: null,
};
@@ -35,4 +35,5 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
description: "Pushes the developer's branch to the remote and opens a pull request.",
systemPrompt: SUBMITTER_SYSTEM,
schema: submitterMetaSchema,
extractRefs: null,
};
@@ -29,7 +29,10 @@ export function createAgentAdapter<Opt>(
return <T>(prompt: string, schema: z.ZodType<T>) => {
return async (ctx: ThreadContext, runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const options = await extract(ctx, prompt, runtime);
const raw = await agent(ctx, options);
const raw = await (agent as (ctx: ThreadContext, optionsParam: Opt) => Promise<string>)(
ctx,
options,
);
const contentHash = await putContentNodeWithRefs(runtime.cas, raw, []);
const extracted = await runtime.extract(
schema as z.ZodType<Record<string, unknown>>,
@@ -39,3 +42,7 @@ export function createAgentAdapter<Opt>(
};
};
}
export function createSimpleAgentAdapter(agent: AgentFn<void>): AdapterFn {
return createAgentAdapter(agent, async () => undefined as unknown as undefined);
}
+1 -1
View File
@@ -1,4 +1,4 @@
export { buildAgentPrompt, buildThreadInput } from "./build-agent-prompt.js";
export { buildThreadInput } from "./build-agent-prompt.js";
export { createAgentAdapter } from "./create-agent-adapter.js";
export type { SpawnCliError } from "./spawn-cli.js";
export { spawnCli } from "./spawn-cli.js";
@@ -0,0 +1,343 @@
import { describe, expect, it } from "vitest";
import type { AgentFrontmatter } from "../src/index.js";
import { parseFrontmatterMarkdown, validateFrontmatter } from "../src/index.js";
// ── parseFrontmatterMarkdown ─────────────────────────────────────────────────
describe("parseFrontmatterMarkdown", () => {
describe("no frontmatter", () => {
it("returns null frontmatter and full text as body when no fence", () => {
const raw = "Just some markdown text.\n\n## Section\n\nContent.";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("returns null frontmatter when --- appears mid-document", () => {
const raw = "# Heading\n\n---\n\nContent.";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("returns null frontmatter when opening fence is not followed by newline", () => {
const raw = "--- inline content ---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("returns null frontmatter when no closing fence", () => {
const raw = "---\nstatus: done\nbody without close";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).toBeNull();
expect(result.body).toBe(raw);
});
it("handles empty string", () => {
const result = parseFrontmatterMarkdown("");
expect(result.frontmatter).toBeNull();
expect(result.body).toBe("");
});
});
describe("full frontmatter document", () => {
it("parses all fields from a well-formed document", () => {
const raw = `---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/foo.ts
- src/bar.ts
scope: thread
---
## Summary
Everything looks good.`;
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBe("done");
expect(fm.next).toBe("reviewer");
expect(fm.confidence).toBe(0.9);
expect(fm.artifacts).toEqual(["src/foo.ts", "src/bar.ts"]);
expect(fm.scope).toBe("thread");
expect(result.body).toBe("## Summary\n\nEverything looks good.");
});
it("strips leading newline from body", () => {
const raw = "---\nstatus: done\n---\n\nbody here";
const result = parseFrontmatterMarkdown(raw);
expect(result.body).toBe("body here");
});
it("body is empty string when nothing after closing fence", () => {
const raw = "---\nstatus: done\n---\n";
const result = parseFrontmatterMarkdown(raw);
expect(result.body).toBe("");
});
it("body is empty string when document ends exactly at closing fence", () => {
const raw = "---\nstatus: done\n---";
const result = parseFrontmatterMarkdown(raw);
expect(result.body).toBe("");
});
});
describe("status field", () => {
it.each([
"done",
"needs_input",
"in_progress",
"failed",
] as const)('parses status "%s"', (status) => {
const raw = `---\nstatus: ${status}\n---\nbody`;
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe(status);
});
it("returns null status for unknown value", () => {
const raw = "---\nstatus: unknown_value\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBeNull();
});
it("returns null status when omitted", () => {
const raw = "---\nconfidence: 0.5\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBeNull();
});
});
describe("confidence field", () => {
it("parses integer as number", () => {
const raw = "---\nconfidence: 1\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(1);
});
it("parses decimal", () => {
const raw = "---\nconfidence: 0.75\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBe(0.75);
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
it("returns null for non-numeric value", () => {
const raw = "---\nconfidence: high\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.confidence).toBeNull();
});
});
describe("artifacts field", () => {
it("parses block sequence", () => {
const raw = "---\nartifacts:\n - a.ts\n - b.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("parses inline sequence", () => {
const raw = "---\nartifacts: [a.ts, b.ts]\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["a.ts", "b.ts"]);
});
it("returns empty array when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual([]);
});
it("wraps single scalar in array", () => {
const raw = "---\nartifacts: only-one.ts\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.artifacts).toEqual(["only-one.ts"]);
});
});
describe("scope field", () => {
it('parses scope "role"', () => {
const raw = "---\nscope: role\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('parses scope "thread"', () => {
const raw = "---\nscope: thread\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("thread");
});
it('defaults to "role" when omitted', () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
it('defaults to "role" for unknown scope value', () => {
const raw = "---\nscope: global\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.scope).toBe("role");
});
});
describe("next field", () => {
it("parses a role name", () => {
const raw = "---\nnext: planner\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBe("planner");
});
it("returns null when omitted", () => {
const raw = "---\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.next).toBeNull();
});
});
describe("unknown fields", () => {
it("ignores unknown keys silently", () => {
const raw = "---\nunknown_field: some_value\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe("done");
});
});
describe("YAML comments", () => {
it("ignores YAML comment lines", () => {
const raw = "---\n# this is a comment\nstatus: done\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter?.status).toBe("done");
});
});
describe("empty frontmatter block", () => {
it("parses empty frontmatter and uses all defaults", () => {
const raw = "---\n---\nbody";
const result = parseFrontmatterMarkdown(raw);
expect(result.frontmatter).not.toBeNull();
const fm = result.frontmatter!;
expect(fm.status).toBeNull();
expect(fm.next).toBeNull();
expect(fm.confidence).toBeNull();
expect(fm.artifacts).toEqual([]);
expect(fm.scope).toBe("role");
expect(result.body).toBe("body");
});
});
});
// ── validateFrontmatter ──────────────────────────────────────────────────────
function validFm(overrides: Partial<AgentFrontmatter> = {}): AgentFrontmatter {
return {
status: "done",
next: null,
confidence: null,
artifacts: [],
scope: "role",
...overrides,
};
}
describe("validateFrontmatter", () => {
it("returns no errors for a fully valid frontmatter", () => {
const errors = validateFrontmatter(validFm());
expect(errors).toHaveLength(0);
});
it("returns no errors when all nullable fields are null", () => {
const fm: AgentFrontmatter = {
status: null,
next: null,
confidence: null,
artifacts: [],
scope: "role",
};
expect(validateFrontmatter(fm)).toHaveLength(0);
});
describe("confidence validation", () => {
it("accepts 0.0", () => {
expect(validateFrontmatter(validFm({ confidence: 0 }))).toHaveLength(0);
});
it("accepts 1.0", () => {
expect(validateFrontmatter(validFm({ confidence: 1 }))).toHaveLength(0);
});
it("rejects value below 0", () => {
const errors = validateFrontmatter(validFm({ confidence: -0.1 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
it("rejects value above 1", () => {
const errors = validateFrontmatter(validFm({ confidence: 1.01 }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("confidence");
});
});
describe("next validation", () => {
it("accepts a simple role name", () => {
expect(validateFrontmatter(validFm({ next: "reviewer" }))).toHaveLength(0);
});
it("accepts kebab-case role name", () => {
expect(validateFrontmatter(validFm({ next: "code-reviewer" }))).toHaveLength(0);
});
it("rejects role name with whitespace", () => {
const errors = validateFrontmatter(validFm({ next: "role name" }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("next");
});
});
describe("artifacts validation", () => {
it("accepts non-empty path strings", () => {
expect(
validateFrontmatter(validFm({ artifacts: ["src/foo.ts", "src/bar.ts"] })),
).toHaveLength(0);
});
it("rejects empty string artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [""] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
it("rejects whitespace-only artifact entries", () => {
const errors = validateFrontmatter(validFm({ artifacts: [" "] }));
expect(errors).toHaveLength(1);
expect(errors[0]?.field).toBe("artifacts");
});
});
describe("multiple errors", () => {
it("reports multiple violations at once", () => {
const fm: AgentFrontmatter = {
status: "done",
next: "bad role",
confidence: 2,
artifacts: [""],
scope: "role",
};
const errors = validateFrontmatter(fm);
const fields = errors.map((e) => e.field);
expect(fields).toContain("next");
expect(fields).toContain("confidence");
expect(fields).toContain("artifacts");
});
});
});
@@ -0,0 +1,291 @@
import type {
AgentFrontmatter,
FrontmatterScope,
FrontmatterStatus,
FrontmatterValidationError,
ParsedFrontmatterMarkdown,
} from "./types.js";
// ── YAML frontmatter extractor ───────────────────────────────────────────────
const FENCE = "---";
/**
* Split a raw agent response into a YAML string (or null) and a markdown body.
*
* A frontmatter block MUST:
* 1. Start at character position 0 with `---` (no leading whitespace / BOM).
* 2. Be closed by a second `---` on its own line.
*
* Anything that doesn't match this shape is returned verbatim as the body.
*/
function splitFrontmatter(raw: string): { yaml: string | null; body: string } {
if (!raw.startsWith(FENCE)) {
return { yaml: null, body: raw };
}
const rest = raw.slice(FENCE.length);
// The opening `---` must be followed immediately by a newline (or end-of-string).
if (rest.length > 0 && rest[0] !== "\n" && rest[0] !== "\r") {
return { yaml: null, body: raw };
}
// Consume the newline after the opening fence so that `afterOpen` starts at the
// first line of YAML content (not a leading empty line).
const afterOpen = rest.startsWith("\n") ? rest.slice(1) : rest;
const closeIndex = afterOpen.indexOf(`\n${FENCE}`);
if (closeIndex === -1) {
// Also handle the edge case where frontmatter is empty: `---\n---`
if (afterOpen.startsWith(FENCE)) {
const afterClose = afterOpen.slice(FENCE.length);
const body = afterClose.replace(/^\n+/, "");
return { yaml: "", body };
}
return { yaml: null, body: raw };
}
const yaml = afterOpen.slice(0, closeIndex);
// Skip past `\n---` and strip any leading blank separator lines from the body.
const afterClose = afterOpen.slice(closeIndex + 1 + FENCE.length);
const body = afterClose.replace(/^\n+/, "");
return { yaml, body };
}
// ── Minimal YAML scalar parser ───────────────────────────────────────────────
//
// We intentionally avoid a full YAML library dependency inside workflow-util.
// The frontmatter schema is flat and uses only scalars + simple string lists.
// This parser handles exactly what the spec needs and nothing more.
type YamlValue = string | number | boolean | null | string[];
function parseYamlScalar(raw: string): YamlValue {
const trimmed = raw.trim();
// Quoted string
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
const lower = trimmed.toLowerCase();
if (lower === "true") return true;
if (lower === "false") return false;
if (lower === "null" || lower === "~" || lower === "") return null;
const num = Number(trimmed);
if (!Number.isNaN(num) && trimmed !== "") return num;
return trimmed;
}
function collectBlockSequence(
lines: string[],
startIdx: number,
): { items: string[]; nextIdx: number } {
const items: string[] = [];
let i = startIdx;
while (i < lines.length) {
const itemTrimmed = (lines[i] ?? "").trimStart();
if (!itemTrimmed.startsWith("- ")) break;
items.push(itemTrimmed.slice(2).trim());
i++;
}
return { items, nextIdx: i };
}
function parseInlineSequence(restTrimmed: string): string[] {
const inner = restTrimmed.slice(1, -1);
return inner
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "");
}
function parseKeyValue(
lines: string[],
i: number,
): { key: string; value: YamlValue; nextIdx: number } | null {
const line = lines[i] ?? "";
if (line.trim() === "" || line.trimStart().startsWith("#")) {
return null;
}
const colonIdx = line.indexOf(":");
if (colonIdx === -1) {
return null;
}
const key = line.slice(0, colonIdx).trim();
const restTrimmed = line.slice(colonIdx + 1).trim();
if (restTrimmed === "") {
const { items, nextIdx } = collectBlockSequence(lines, i + 1);
return { key, value: items, nextIdx };
}
if (restTrimmed.startsWith("[") && restTrimmed.endsWith("]")) {
return { key, value: parseInlineSequence(restTrimmed), nextIdx: i + 1 };
}
return { key, value: parseYamlScalar(restTrimmed), nextIdx: i + 1 };
}
/**
* Parse a minimal flat YAML document. Only supports:
* - Scalar key: value pairs
* - Block sequences under a key (items prefixed with ` - `)
*
* Returns a plain object. Throws on structural errors.
*/
function parseMinimalYaml(yaml: string): Record<string, YamlValue> {
const result: Record<string, YamlValue> = {};
const lines = yaml.split("\n");
let i = 0;
while (i < lines.length) {
const entry = parseKeyValue(lines, i);
if (entry === null) {
i++;
continue;
}
result[entry.key] = entry.value;
i = entry.nextIdx;
}
return result;
}
// ── Field coercers ───────────────────────────────────────────────────────────
const VALID_STATUS: readonly FrontmatterStatus[] = ["done", "needs_input", "in_progress", "failed"];
const VALID_SCOPE: readonly FrontmatterScope[] = ["role", "thread"];
function coerceStatus(raw: YamlValue): FrontmatterStatus | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase();
return VALID_STATUS.includes(s as FrontmatterStatus) ? (s as FrontmatterStatus) : null;
}
function coerceNext(raw: YamlValue): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
return s === "" ? null : s;
}
function coerceConfidence(raw: YamlValue): number | null {
if (raw === null || raw === undefined) return null;
const n = typeof raw === "number" ? raw : Number(String(raw).trim());
if (Number.isNaN(n)) return null;
return n;
}
function coerceArtifacts(raw: YamlValue): readonly string[] {
if (raw === null || raw === undefined) return [];
if (Array.isArray(raw)) return raw.map(String).filter((s) => s !== "");
const s = String(raw).trim();
return s === "" ? [] : [s];
}
function coerceScope(raw: YamlValue): FrontmatterScope {
if (raw === null || raw === undefined) return "role";
const s = String(raw).trim().toLowerCase();
return VALID_SCOPE.includes(s as FrontmatterScope) ? (s as FrontmatterScope) : "role";
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Parse a raw agent response string into structured frontmatter + body.
*
* - Never throws: malformed YAML is silently treated as "no frontmatter".
* - The returned `frontmatter` is `null` when no valid `---…---` block was found.
* - Unknown YAML keys are silently ignored.
* - Invalid scalar values for known keys are coerced to their null/default.
*/
export function parseFrontmatterMarkdown(raw: string): ParsedFrontmatterMarkdown {
const { yaml, body } = splitFrontmatter(raw);
if (yaml === null) {
return { frontmatter: null, body };
}
let fields: Record<string, YamlValue>;
try {
fields = parseMinimalYaml(yaml);
} catch {
// Unparseable YAML → treat as no frontmatter; keep full raw as body.
return { frontmatter: null, body: raw };
}
const frontmatter: AgentFrontmatter = {
status: coerceStatus(fields.status ?? null),
next: coerceNext(fields.next ?? null),
confidence: coerceConfidence(fields.confidence ?? null),
artifacts: coerceArtifacts(fields.artifacts ?? null),
scope: coerceScope(fields.scope ?? null),
};
return { frontmatter, body };
}
/**
* Validate a parsed `AgentFrontmatter` and return a list of violations.
*
* An empty array means the frontmatter is valid.
*
* Validated constraints:
* - `status` — must be one of the FrontmatterStatus literals (if non-null)
* - `confidence` — must be in [0.0, 1.0] (if non-null)
* - `next` — must be a non-empty string with no whitespace (if non-null)
* - `artifacts` — each entry must be a non-empty string
* - `scope` — must be one of the FrontmatterScope literals
*/
export function validateFrontmatter(
frontmatter: AgentFrontmatter,
): readonly FrontmatterValidationError[] {
const errors: FrontmatterValidationError[] = [];
if (frontmatter.status !== null && !VALID_STATUS.includes(frontmatter.status)) {
errors.push({
field: "status",
message: `invalid status "${frontmatter.status}"; must be one of: ${VALID_STATUS.join(", ")}`,
});
}
if (frontmatter.confidence !== null) {
if (frontmatter.confidence < 0 || frontmatter.confidence > 1) {
errors.push({
field: "confidence",
message: `confidence ${frontmatter.confidence} is out of range; must be between 0.0 and 1.0 inclusive`,
});
}
}
if (frontmatter.next !== null) {
if (frontmatter.next.trim() === "") {
errors.push({ field: "next", message: "next must be a non-empty string when present" });
} else if (/\s/.test(frontmatter.next)) {
errors.push({
field: "next",
message: `next "${frontmatter.next}" must not contain whitespace`,
});
}
}
for (const artifact of frontmatter.artifacts) {
if (artifact.trim() === "") {
errors.push({ field: "artifacts", message: "artifact entries must be non-empty strings" });
break;
}
}
if (!VALID_SCOPE.includes(frontmatter.scope)) {
errors.push({
field: "scope",
message: `invalid scope "${frontmatter.scope}"; must be one of: ${VALID_SCOPE.join(", ")}`,
});
}
return errors;
}

Some files were not shown because too many files have changed in this diff Show More