Compare commits

..

14 Commits

Author SHA1 Message Date
xiaoju 34e00bebdf chore: switch json-cas deps from file: to npm ^0.1.0
小橘 <xiaoju@shazhou.work>
2026-05-18 02:49:49 +00:00
xiaoju 33cf23ed01 Merge pull request 'feat: Phase 5+6 — React layer + Agent model' (#303) from feat/294-phase5-react-layer into main 2026-05-18 02:47:31 +00:00
xiaoju 94c719870f feat: Phase 5 — React layer instrumentation
- json-cas-react-recorder.ts: writeReactSession stores full ReAct trace
- ReactTrace/ReactTurnTrace/ReactToolCallTrace types
- JsonCasAgentResult with optional react trace
- Engine integration: real react-session when trace provided, placeholder when null
- 10 new tests (29 total for json-cas engine), biome clean

Closes #301
小橘 <xiaoju@shazhou.work>
2026-05-18 02:47:18 +00:00
xiaoju 5af2d54e0f Merge pull request 'feat: Phase 4 — json-cas engine migration' (#300) from feat/294-phase4-engine-migration into main 2026-05-18 02:38:04 +00:00
xiaoju e01c08dacb feat: Phase 4 — json-cas engine (new engine alongside old)
- json-cas-engine.ts: new engine using json-cas Store + typed nodes
- json-cas-context.ts: build ThreadContext from thread-step chain
- json-cas-types.ts: engine types (JsonCasEngineIo, JsonCasAgentFn, etc.)
- thread-start/step/end/content nodes in json-cas format
- JSONata moderator via evaluateModerator
- react placeholder (Phase 5 will fill in)
- 21 tests passing, biome clean

Closes #299
小橘 <xiaoju@shazhou.work>
2026-05-18 02:37:05 +00:00
xiaoju f9d3d38008 Merge pull request 'feat: Phase 3 — workflow JSON definitions in CAS' (#298) from feat/294-phase3-workflow-json into main 2026-05-18 02:27:55 +00:00
xiaoju 9e99e58405 feat: Phase 3 — workflow JSON definitions in CAS
- New package: @uncaged/workflow-json-def
- registerWorkflow/loadWorkflow for CAS round-trip
- solve-issue and develop templates converted to pure JSON
- Zod schemas → JSON Schema, moderator → JSONata
- 30 tests passing, biome clean

Closes #297
小橘 <xiaoju@shazhou.work>
2026-05-18 02:24:52 +00:00
xiaoju 6af3059fb4 Merge pull request 'feat: JSONata moderator engine (Phase 2 of #294)' (#296) from feat/294-jsonata-moderator into main 2026-05-18 02:13:56 +00:00
xiaoju dfeba9d8fc feat: JSONata moderator engine (Phase 2 of #294)
- evaluateModerator(rules, context) with JSONata expression evaluation
- Fallback (when: null), conditional branching, no-match → __end__
- Full develop workflow moderator ported to JSONata
- 31 tests passing

Closes #295
小橘 <xiaoju@shazhou.work>
2026-05-18 02:08:03 +00:00
xingyue 0da1aabfab Merge pull request 'docs: add .cards/ architecture documentation' (#293) from docs/architecture-cards into main 2026-05-17 07:40:09 +00:00
xiaoju bb3618cc42 chore: remove _spec.md from cards (build artifact)
小橘 <xiaoju@shazhou.work>
2026-05-17 07:39:04 +00:00
xiaoju 2b21d981dd docs: add .cards/ architecture documentation
12 interlinked architecture cards + index, covering:
- Core: Bundle, Thread, CAS, Registry
- Execution: Engine, Role, Agent Binding, Reactor
- Tooling: CLI, Dashboard, Package Map
- Authoring: Workflow Templates

小橘 <xiaoju@shazhou.work>
2026-05-17 07:34:02 +00:00
xiaomo ebfb99bf4c Merge pull request 'refactor: rename casRef to x-cas-ref for JSON Schema compliance (Phase 3)' (#292) from feat/285-phase3-x-cas-ref into main 2026-05-16 11:55:24 +00:00
xiaoju 33f9425848 refactor: rename casRef to x-cas-ref for JSON Schema compliance
Rename .meta({ casRef: true }) to .meta({ 'x-cas-ref': true }) across
all schema annotations. Zod v4 toJSONSchema automatically outputs
x-cas-ref in the generated JSON Schema, so buildDescriptor preserves
the annotation without any code changes.

Add buildDescriptor test verifying x-cas-ref appears in descriptor
output for annotated fields and is absent for plain fields.

220 tests pass, 0 fail.

Fixes #291, Refs #285
2026-05-16 11:52:17 +00:00
650 changed files with 8057 additions and 31568 deletions
+35
View File
@@ -0,0 +1,35 @@
# 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
@@ -0,0 +1,104 @@
# 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
@@ -0,0 +1,83 @@
# 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
@@ -0,0 +1,111 @@
# 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
@@ -0,0 +1,107 @@
# 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
@@ -0,0 +1,74 @@
# 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
@@ -0,0 +1,110 @@
# 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
@@ -0,0 +1,129 @@
# 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
@@ -0,0 +1,102 @@
# 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
@@ -0,0 +1,95 @@
# 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
@@ -0,0 +1,72 @@
# 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
@@ -0,0 +1,97 @@
# 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
@@ -0,0 +1,153 @@
# 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`
-67
View File
@@ -1,67 +0,0 @@
# Sync README
When updating README.md files in this monorepo, follow these conventions.
## Scope
- Root `README.md` — project overview and navigation hub
- Per-package `packages/*/README.md` — each package self-contained
## Root README Structure
The root README should have these sections in order:
1. **Title and one-liner** — stateless workflow engine driven by single-step CLI
2. **Overview** — 2-3 paragraphs explaining what it does and key concepts
3. **Architecture** — dependency layer diagram (text-based)
4. **Packages** — table with ALL packages from packages/ directory, columns: Package, Description, Type (cli/lib/agent/app)
5. **Quick Start** — install, build, register workflow, start thread, run step
6. **CLI Reference** — brief command list, detailed usage in cli-workflow README
7. **Development** — bun install / build / check / test
## Per-Package README Structure
Each package README should have:
1. **Title** — package name
2. **One-line description** — matching package.json
3. **Overview** — what it does, where it sits in the architecture, dependencies
4. **Installation** — bun add (for libs) or "included as binary" (for cli/agents)
5. **API** (lib packages) — all exports from src/index.ts with type signatures, grouped by category, minimal usage examples
6. **CLI Usage** (cli/agent packages) — command reference with examples
7. **Internal Structure** — brief src/ file organization
8. **Configuration** (if applicable)
## Execution Steps
### Step 1: Gather current state
For each package read:
- package.json (name, version, description, dependencies, bin)
- src/index.ts (public API exports)
- Existing README.md (preserve hand-written content worth keeping)
### Step 2: Update root README
- Ensure ALL packages in packages/ directory are listed in the table
- Update CLI command reference from uwf --help output
- Keep Quick Start examples valid
### Step 3: Write/update each package README
- Follow the per-package structure
- API section MUST match actual src/index.ts exports — never invent
- For agent packages: document CLI binary name, how it is invoked
- For lib packages: document exported types and functions
- Internal structure: list actual files in src/
### Step 4: Verify
- All relative links work
- Package names match package.json
- No references to removed/renamed packages
- bun run build still passes
## Guidelines
- Only document what src/index.ts actually exports
- Root README summarizes, package READMEs go into detail
- Verify CLI examples against actual commands
- Preserve existing good prose when updating
- English for all README content
+40
View File
@@ -0,0 +1,40 @@
# ──────────────────────────────────────────────
# Workflow Engine — Environment Variables
# ──────────────────────────────────────────────
# Copy this file to .env and fill in the values.
# ── Cursor Agent ──
# CLI command to invoke the Cursor agent (required for develop workflow)
WORKFLOW_CURSOR_COMMAND=
# Model override for Cursor agent
WORKFLOW_CURSOR_MODEL=
# Timeout in milliseconds for Cursor agent operations
WORKFLOW_CURSOR_TIMEOUT=
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
# CLI command to invoke the Hermes agent (absolute path required)
WORKFLOW_HERMES_COMMAND=
# Model override for Hermes agent
WORKFLOW_HERMES_MODEL=
# Timeout in milliseconds for Hermes agent operations
WORKFLOW_HERMES_TIMEOUT=
# ── Storage ──
# Override the workflow storage root directory
# Default: ~/.uncaged/workflow
WORKFLOW_STORAGE_ROOT=
# Gateway secret for the serve command
WORKFLOW_DASHBOARD_SECRET=
# ── Display ──
# Set to any value to disable colored output
# NO_COLOR=1
-4
View File
@@ -9,7 +9,3 @@ bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
*.py
.claude
tmp
-83
View File
@@ -1,83 +0,0 @@
# Test Spec: uwf setup model connectivity validation (#335)
## Context
File: `packages/cli-workflow/src/commands/setup.ts`
Test file: `packages/cli-workflow/src/__tests__/setup-validate.test.ts`
After `cmdSetup` writes config, it should send a test chat completion request to verify the configured model is reachable. If validation fails, warn the user (don't abort — config is already saved).
## Implementation Notes
- Add a `validateModel(baseUrl, apiKey, model)` function that sends a minimal chat completion request (`POST /chat/completions` with `messages: [{role:"user",content:"hi"}]`, `max_tokens: 1`)
- Returns `Result<void, string>` — ok if 2xx response, error with reason string otherwise
- Use `AbortSignal.timeout(15_000)` for the request
- Both `cmdSetup` and `cmdSetupInteractive` should call it after saving config
- `cmdSetup` returns validation result in its return object: `{ ...existing, validation: { ok: true } | { ok: false, error: string } }`
- `cmdSetupInteractive` prints a warning to console if validation fails, success message if it passes
- Use the project logger (`createLogger`) — no raw `console.log` except in interactive CLI output (per CLAUDE.md)
## Test Cases (vitest)
### 1. `validateModel` — success path
- Mock `fetch` to return `{ status: 200, ok: true, json: () => ({}) }`
- Call `validateModel(baseUrl, apiKey, model)`
- Assert returns `{ ok: true, value: undefined }`
- Assert fetch was called with correct URL (`${baseUrl}/chat/completions`), correct headers (`Authorization: Bearer ${apiKey}`), correct body (model, messages, max_tokens: 1)
### 2. `validateModel` — HTTP error (401 unauthorized)
- Mock `fetch` to return `{ status: 401, ok: false, statusText: "Unauthorized" }`
- Call `validateModel(baseUrl, apiKey, model)`
- Assert returns `{ ok: false, error: <string containing "401"> }`
### 3. `validateModel` — HTTP error (404 model not found)
- Mock `fetch` to return `{ status: 404, ok: false, statusText: "Not Found" }`
- Assert returns `{ ok: false, error: <string containing "404"> }`
### 4. `validateModel` — network timeout
- Mock `fetch` to throw `DOMException` with name `AbortError`
- Assert returns `{ ok: false, error: <string containing "timeout" or "unreachable"> }`
### 5. `validateModel` — network error (DNS failure, connection refused)
- Mock `fetch` to throw `TypeError("fetch failed")`
- Assert returns `{ ok: false, error: <string mentioning connectivity> }`
### 6. `cmdSetup` — includes validation result on success
- Mock global `fetch` for `/chat/completions` to succeed
- Call `cmdSetup({ provider, baseUrl, apiKey, model, storageRoot })`
- Assert returned object has `validation: { ok: true, value: undefined }`
- Assert config files are still written (existing behavior preserved)
### 7. `cmdSetup` — includes validation result on failure (config still saved)
- Mock global `fetch` for `/chat/completions` to return 401
- Call `cmdSetup({ ... })`
- Assert returned object has `validation: { ok: false, error: ... }`
- Assert `config.yaml` and `.env` are still written (validation failure doesn't prevent saving)
### 8. `cmdSetupInteractive` — prints success message on validation pass
- Mock `fetch` for both `/models` and `/chat/completions` to succeed
- Mock stdin to provide valid selections
- Capture console output
- Assert output contains a success message like "Model verified" or "✓"
### 9. `cmdSetupInteractive` — prints warning on validation failure
- Mock `fetch`: `/models` succeeds, `/chat/completions` returns 401
- Mock stdin for valid selections
- Capture console output
- Assert output contains a warning about model not being reachable and suggests trying a different model
### 10. `validateModel` — request body correctness
- Mock `fetch` to capture the request body
- Call `validateModel(baseUrl, apiKey, "test-model")`
- Assert body is `{ model: "test-model", messages: [{role: "user", content: "hi"}], max_tokens: 1 }`
## Export Requirements
- `validateModel` must be exported (for direct unit testing)
- Signature: `async function validateModel(baseUrl: string, apiKey: string, model: string): Promise<Result<void, string>>`
- `Result` type: `{ ok: true; value: T } | { ok: false; error: E }` (project convention)
## Files to Create/Modify
- **New**: `packages/cli-workflow/src/__tests__/setup-validate.test.ts` — all test cases above
- **Modify**: `packages/cli-workflow/src/commands/setup.ts` — add `validateModel`, integrate into `cmdSetup` and `cmdSetupInteractive`
-216
View File
@@ -1,216 +0,0 @@
name: "solve-issue"
description: "TDD-driven issue resolution for small, focused changes. Loop protection relies on engine maxRounds."
roles:
planner:
description: "Analyzes issue and outputs a TDD test spec"
goal: "You are a planning agent. You analyze Gitea issues and produce a TDD test specification that downstream roles will implement and verify."
capabilities:
- issue-analysis
- planning
procedure: |
On first run (no previous steps):
1. Read the issue and all comments from Gitea using `tea issues <number> -r <owner/repo>`
2. Read CLAUDE.md (or equivalent project conventions file) to understand coding standards
3. Assess whether the issue has enough information to produce a test spec
4. If insufficient info: comment on the issue via `echo "..." | tea comment <number> -r <owner/repo>` (skip if you already commented), then output status=insufficient_info and terminate
5. If sufficient: produce a detailed TDD test spec in markdown covering all scenarios
On subsequent runs (bounced back by tester with fix_spec):
1. Read the tester's output from the previous step to understand what's wrong with the spec
2. Revise the test spec accordingly
After producing the test spec:
1. Store it via `uwf cas put-text "<markdown content>"` and capture the returned hash
2. Put the hash in frontmatter.plan (required when status=ready)
output: "Output a brief summary of the test spec. Frontmatter must include: status (ready or insufficient_info) and plan (CAS hash of the test spec, required when status=ready)."
frontmatter:
type: object
properties:
status:
type: string
enum: [ready, insufficient_info]
plan:
type: string
required: [status]
developer:
description: "TDD implementation per test spec"
goal: "You are a developer agent. You implement code changes following TDD — write tests first, then implementation."
capabilities:
- coding
procedure: |
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
Before starting any work, set up an isolated worktree:
1. `cd ~/repos/workflow && git fetch origin` to get latest refs
2. First time (no existing branch):
- `git worktree add ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> -b fix/<issue-number>-<short-slug> origin/main`
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug> && bun install`
3. If bounced back from reviewer or tester (branch already exists):
- The worktree should already exist at `~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
- `cd ~/repos/workflow-worktrees/fix/<issue-number>-<short-slug>`
- `git fetch origin && git rebase origin/main`
4. ALL subsequent work must happen inside the worktree directory.
Then implement TDD:
5. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
6. If bounced back from reviewer or tester: read the previous role's output to understand what needs fixing
7. Write tests first based on the spec
8. Implement the code to make tests pass
9. Ensure `bun run build` passes with no errors
10. Run `bun test` to verify all tests pass
output: "List all files changed and provide a summary. Frontmatter must include: status (done or failed)."
frontmatter:
type: object
properties:
status:
type: string
enum: [done, failed]
required: [status]
reviewer:
description: "Code standards compliance check"
goal: "You are a code reviewer. You verify code standards compliance — NOT functionality (that's the tester's job)."
capabilities:
- code-review
- static-analysis
procedure: |
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
Before reviewing, verify the git branch:
1. Run `git branch --show-current` — confirm the branch name references the issue number being worked on
2. If the branch doesn't correspond to the issue, flag it in your output and reject
Then perform code review:
Hard checks (must all pass):
3. `bun run build` — no build errors
4. `bunx biome check` — no lint violations
5. TypeScript strict mode — no type errors
Soft checks (review against CLAUDE.md conventions):
- Functional-first: `function` + `type`, not `class` + `interface`
- No optional properties (`?:`) — use `T | null`
- Naming conventions (kebab-case files, PascalCase types, camelCase functions)
- Module boundary discipline (folder exports via index.ts)
- No `console.log` (use structured logger)
- No dynamic imports in production code
Only review standards compliance. Do NOT test functionality.
If rejecting, you MUST explain the specific reason in your output.
output: "Explain your decision with specific file/line references. Frontmatter must include: approved (true or false)."
frontmatter:
type: object
properties:
approved:
type: boolean
required: [approved]
tester:
description: "Functional correctness verification"
goal: "You are a tester agent. You verify that the implementation correctly satisfies every scenario in the test spec."
capabilities:
- testing
procedure: |
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
1. Run `bun test` for automated test verification
2. Read the test spec from CAS: `uwf cas get <plan hash>` (find the hash from the latest planner step's frontmatter.plan)
3. Verify each scenario in the spec is covered and passing
4. Determine outcome:
- passed: all scenarios verified, tests pass
- fix_code: tests fail or implementation doesn't match spec → send back to developer
- fix_spec: the spec itself is wrong or incomplete → send back to planner
output: "Report test results per scenario. Frontmatter must include: status (passed, fix_code, or fix_spec)."
frontmatter:
type: object
properties:
status:
type: string
enum: [passed, fix_code, fix_spec]
required: [status]
committer:
description: "Commits and creates PR"
goal: "You are a committer agent. You create a clean commit and push a PR linking the original issue."
capabilities: []
procedure: |
First, cd into the worktree: `cd ~/repos/workflow-worktrees/fix/<issue-number>-*` (find the exact directory)
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
1. Stage all changes: `git add -A`
2. Commit with a descriptive message referencing the issue: `git commit -m "type: description\n\nFixes #N"`
3. Push the branch: `git push -u origin <branch-name>`
- If push hook fails: capture the error log in your output, mark hook_failed
4. On push success: create a PR via `tea pr create --repo uncaged/workflow --title "..." --description "..."`
- The `--repo` flag is required to work in worktree directories (fixes #474 "path segment [0] is empty" error)
- If working on a different repo, extract owner/repo from: `git remote get-url origin | sed 's/.*[:/]\([^/]*\/[^.]*\).*/\1/'`
- PR description must follow the project template: What / Why / Changes / Ref sections, with `Fixes #N` in Ref
- On tea failure: capture stderr/stdout, log the error clearly, include PR details (title, description, branch) for manual creation, and mark success=false
5. After PR creation, clean up the worktree:
- `cd ~/repos/workflow`
- `git worktree remove ~/repos/workflow-worktrees/fix/<issue-number>-<slug>`
output: "Include PR URL on success or error log on failure. Frontmatter must include: success (true or false)."
frontmatter:
type: object
properties:
success:
type: boolean
required: [success]
conditions:
insufficientInfo:
description: "Planner determined there's not enough info to proceed"
expression: "$last('planner').status = 'insufficient_info'"
devFailed:
description: "Developer failed to implement"
expression: "$last('developer').status = 'failed'"
rejected:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
fixCode:
description: "Tester found code issues"
expression: "$last('tester').status = 'fix_code'"
fixSpec:
description: "Tester found spec issues"
expression: "$last('tester').status = 'fix_spec'"
hookFailed:
description: "Push hook failed"
expression: "$last('committer').success = false"
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue and produce an implementation plan."
planner:
- role: "$END"
condition: "insufficientInfo"
prompt: "Insufficient information to proceed; end the workflow."
- role: "developer"
condition: null
prompt: "Implement the plan from the planner."
developer:
- role: "$END"
condition: "devFailed"
prompt: "Development failed; end the workflow."
- role: "reviewer"
condition: null
prompt: "Send the implementation to the reviewer."
reviewer:
- role: "developer"
condition: "rejected"
prompt: "Reviewer rejected the implementation; fix the issues."
- role: "tester"
condition: null
prompt: "Review passed; run tests on the implementation."
tester:
- role: "developer"
condition: "fixCode"
prompt: "Tests found code issues; return to developer."
- role: "planner"
condition: "fixSpec"
prompt: "Tests found spec issues; return to planner."
- role: "committer"
condition: null
prompt: "Tests passed; commit and push the changes."
committer:
- role: "developer"
condition: "hookFailed"
prompt: "Push hook failed; return to developer to fix."
- role: "$END"
condition: null
prompt: "Commit succeeded; complete the workflow."
+69 -64
View File
@@ -2,41 +2,46 @@
## Project Overview
This monorepo implements a stateless workflow engine driven by a single-step CLI (`uwf`). Workflows are **YAML definitions** stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
### Key Terms
| Concept | What it is |
|---------|-----------|
| **Workflow** | A YAML definition (`WorkflowPayload`) with roles, conditions, and a routing graph. Stored as a CAS node, identified by its XXH64 hash. |
| **Thread** | A single execution of a workflow, identified by a ULID. State is an immutable CAS chain; active threads indexed in `threads.yaml`; completed threads in `history.jsonl`. |
| **Role** | A named actor within a workflow. Each role has a system prompt and a JSON Schema `outputSchema`. |
| **Moderator** | JSONata-based graph evaluator — determines the next role (or `$END`) with zero LLM cost. |
| **Agent** | An external CLI command (`uwf-hermes`, etc.) spawned by `uwf thread step`. Produces frontmatter markdown output. |
| **CAS** | Content-Addressed Storage via `@uncaged/json-cas` — all workflow definitions, thread nodes, and outputs are immutable CAS nodes. |
| **Registry** | `~/.uncaged/workflow/registry.yaml` — maps workflow names to current CAS hashes. |
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
### Monorepo Structure
```
workflow/
packages/
workflow-protocol/ # @uncaged/workflow-protocol — shared types (WorkflowPayload, StepNodePayload, WorkflowConfig, etc.)
workflow-util/ # @uncaged/workflow-util — Crockford Base32, ULID, logger, frontmatter parsing/validation
workflow-moderator/ # @uncaged/workflow-moderator — JSONata graph evaluator
workflow-agent-kit/ # @uncaged/workflow-agent-kit — createAgent factory, context builder, extract pipeline
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI binary (spawns hermes chat)
cli-workflow/ # @uncaged/cli-workflow — uwf CLI binary
legacy-packages/ # Archived packages (preserved for reference, not active)
examples/ # Workflow YAML examples (solve-issue.yaml)
docs/ # Architecture docs
biome.json # root Biome config
tsconfig.json # root TypeScript config
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
workflow-agent-llm/ # @uncaged/workflow-agent-llm
workflow-agent-react/ # @uncaged/workflow-agent-react
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
workflow-template-develop/ # @uncaged/workflow-template-develop
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
docs/ # RFCs, conventions
biome.json # root Biome config
tsconfig.json # root TypeScript config
```
- Dependency layers: `workflow-protocol` → (`workflow-util`, `workflow-moderator`) → `workflow-agent-kit``workflow-agent-hermes` / `cli-workflow`
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`)`workflow-execute` `cli-workflow`
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
- External CAS: `@uncaged/json-cas` (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend)
## Language & Paradigm
@@ -104,6 +109,8 @@ type WorkflowEntry = {
- Always named exports, never default exports
- One module = one responsibility, filename = purpose
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
### Folder Module Discipline
Every folder under `src/` is a **module boundary**. Four rules:
@@ -129,10 +136,10 @@ export { createCasStore } from "../cas/cas.js";
// ❌ Bad — types defined in index.ts
// in cas/index.ts:
export type CasStore = { ... }; // should be in cas/types.ts
export type CasStore = { ... }; // should be in cas/types.ts
```
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`) are not inside a folder module and follow normal rules.
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
## Naming
@@ -153,7 +160,7 @@ Workflow names use **verb-first** kebab-case:
### ID Encoding
All IDs use **Crockford Base32**:
- CAS hash: XXH64 → 13-char Crockford Base32
- Bundle hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
## Error Handling
@@ -182,7 +189,7 @@ import { createLogger } from "@uncaged/workflow-util";
const log = createLogger();
// Each call site has a fixed 8-char Crockford Base32 tag
log("4KNMR2PX", "Loading workflow...");
log("4KNMR2PX", "Loading workflow bundle...");
log("7BQST3VW", `Role ${role} started`);
```
@@ -197,7 +204,7 @@ log("7BQST3VW", `Role ${role} started`);
### Why fixed tags?
- `grep "4KNMR2PX"` in logs → instant code location
- `grep "4KNMR2PX"` in `.info.jsonl` → instant code location
- No need for file/line info in the log — tag is the locator
- Survives refactoring (tag stays the same when code moves)
@@ -214,76 +221,74 @@ console.log(result);
Do NOT use `await import()` in production code. Always use static top-level `import`.
**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime.
```ts
// Dynamic import required: user bundle path resolved at runtime
const mod = await import(bundlePath);
```
Test files (`__tests__/**`) are exempt.
## Toolchain
| Tool | Purpose |
|------|---------|
| **bun** | Package manager + runtime |
| **bun** | Package manager + runtime + test runner |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format (replaces ESLint + Prettier) |
| **vitest** | Test runner (`cli-workflow` uses vitest; other packages use `bun test`) |
### Development Workflow
### Commands
```bash
# ── Setup ──
bun install # install all workspace dependencies
# ── Daily development ──
bun run build # tsc --build (all packages, dependency order)
bun run check # tsc --build + biome check + lint-log-tags
bun run format # biome format --write
bun test # run tests across all packages
# ── Before committing ──
bun run check # must pass — typecheck + lint + log tag validation
bun test # must pass — all package tests
bun run check # tsc --build + biome check
bun run format # biome format --write
bun test # run tests
```
### Publishing
### Version Management & Publishing
All public `@uncaged/*` packages are published to **npmjs.org** with **fixed mode** (all packages share the same version number).
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
```bash
# 1. Add a changeset describing the change
# 1. After making changes, add a changeset describing the change
bun changeset
# 2. Bump all package versions + generate CHANGELOGs
# 2. Before release, bump all package versions + generate CHANGELOGs
bun version
# 3. Build, test, and publish (runs scripts/publish-all.mjs)
# 3. Build, test, and publish to npmjs
bun release
# Or publish manually with a tag:
node scripts/publish-all.mjs --tag alpha
node scripts/publish-all.mjs --dry-run # preview without publishing
```
- `workspace:^` dependencies resolve to `^x.y.z` on publish
- Publish order defined in `scripts/publish-all.mjs` (dependency order)
- Changesets config: `.changeset/config.json` (fixed mode, public access)
- Each package has auto-generated `CHANGELOG.md`
### End-to-end: Author → Register → Run
### Consuming @uncaged/* Packages
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
### End-to-end: Monorepo → Registry → Workspace → Bundle
```
examples/solve-issue.yaml — write a workflow YAML definition
uwf workflow put
workflow/ (monorepo) — engine, runtime, templates, agents
bun release — build + test + changeset publish
~/.uncaged/workflow/cas/ — Workflow stored as CAS node
~/.uncaged/workflow/registry.yaml — name → hash mapping updated
│ uwf thread start <name> -p "..."
npmjs.org — @uncaged/* scoped packages (public)
│ bun install
~/.uncaged/workflow/threads.yaml — new thread head pointer
uwf thread step <thread-id>
my-workflows/ (workspace) — normal package.json
bun run build:develop — bun build → single .esm.js
moderator → agent → extract — one step per invocation, repeat until $END
uncaged-workflow workflow add — register bundle locally
uncaged-workflow run — execute workflow
```
1. **Author** — write a workflow YAML file with roles, conditions, and graph
2. **Register** `uwf workflow put <file.yaml>` parses YAML, registers output schemas, stores `WorkflowPayload` in CAS
3. **Run** `uwf thread start` creates a thread, `uwf thread step` executes one cycle per invocation
1. **Monorepo changes**`bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
2. **Workspace** `bun install` fetches latest from npmjs
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
4. **Register & Run**`uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
## Commit Convention
@@ -291,5 +296,5 @@ moderator → agent → extract — one step per invocation, repeat until $
<type>(<scope>): <description>
type: feat | fix | refactor | docs | chore | test
scope: workflow | cli | moderator | agent-kit | hermes | util | protocol | ...
scope: workflow | cli | rfc-001 | ...
```
+45 -78
View File
@@ -1,104 +1,71 @@
# @uncaged/workflow
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions with roles, JSONata routing conditions, and a directed graph. Threads are immutable CAS-linked chains — each `uwf thread step` runs one moderator→agent→extract cycle and exits.
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
## Overview
## Core Concepts
This monorepo implements **uwf**, a workflow engine with no long-running daemon. You register YAML workflow definitions in a content-addressed store (CAS), start a thread with an initial prompt, then invoke `uwf thread step` repeatedly until the moderator routes to `$END`. Each step is a complete process: the moderator evaluates JSONata conditions to pick the next role, an external agent CLI produces frontmatter markdown output, and an extract pipeline validates or structures that output against the role's JSON Schema.
| Concept | Description |
|---------|-------------|
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
Workflow state lives entirely on disk under `~/.uncaged/workflow/`: CAS nodes for definitions and step payloads, `registry.yaml` for workflow name→hash mappings, and `threads.yaml` for active thread head pointers. Completed threads are archived to `history.jsonl`. Because there is no server process, workflows are easy to debug, fork, and inspect with ordinary CLI tools.
Agents are pluggable CLI binaries (`uwf-hermes`, `uwf-builtin`, `uwf-claude-code`, or custom commands). The engine spawns the configured agent with `<thread-id>` and `<role>`, sets `UWF_EDGE_PROMPT` from the graph transition, and captures both the agent's markdown output and a detail CAS node for session replay.
## Architecture
Dependency layers (lower layers have no dependency on higher layers):
## Monorepo Packages
```
Layer 0 — Contract
workflow-protocol Shared types and JSON Schema definitions
Layer 1 — Shared infra
workflow-util Encoding, IDs, logging, frontmatter, paths
workflow-moderator JSONata graph evaluator
Layer 2 — Agent framework
workflow-agent-kit createAgent factory, context builder, extract pipeline
Layer 3 — Agent implementations
workflow-agent-hermes Hermes ACP agent (uwf-hermes)
workflow-agent-builtin Built-in LLM + tools agent (uwf-builtin)
workflow-agent-claude-code Claude Code agent (uwf-claude-code)
Layer 4 — CLI
cli-workflow uwf binary — thread lifecycle, registry, CAS, setup
App (uses protocol; not in the runtime engine stack)
workflow-dashboard Web UI for visual workflow editing
packages/
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
```
External CAS: [`@uncaged/json-cas`](https://www.npmjs.com/package/@uncaged/json-cas) (store API, hashing, schema validation) + `@uncaged/json-cas-fs` (filesystem backend).
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, CAS node types, storage layout, agent CLI protocol, and design decisions.
## Packages
| Package | npm | Description | Type | README |
|---------|-----|-------------|------|--------|
| `cli-workflow` | `@uncaged/cli-workflow` | `uwf` CLI — thread lifecycle, workflow registry, CAS inspection, setup | cli | [README](packages/cli-workflow/README.md) |
| `workflow-protocol` | `@uncaged/workflow-protocol` | Shared TypeScript types and JSON Schema constants | lib | [README](packages/workflow-protocol/README.md) |
| `workflow-moderator` | `@uncaged/workflow-moderator` | JSONata graph evaluator — next role or `$END` | lib | [README](packages/workflow-moderator/README.md) |
| `workflow-agent-kit` | `@uncaged/workflow-agent-kit` | `createAgent` factory, context builder, extract pipeline | lib | [README](packages/workflow-agent-kit/README.md) |
| `workflow-util` | `@uncaged/workflow-util` | Crockford Base32, ULID, logger, frontmatter parsing, storage paths | lib | [README](packages/workflow-util/README.md) |
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | `uwf-hermes` — spawns Hermes chat via ACP | agent | [README](packages/workflow-agent-hermes/README.md) |
| `workflow-agent-builtin` | `@uncaged/workflow-agent-builtin` | `uwf-builtin` — built-in LLM agent with file/shell tools | agent | [README](packages/workflow-agent-builtin/README.md) |
| `workflow-agent-claude-code` | `@uncaged/workflow-agent-claude-code` | `uwf-claude-code` — spawns Claude Code CLI | agent | [README](packages/workflow-agent-claude-code/README.md) |
| `workflow-dashboard` | `@uncaged/workflow-dashboard` | Web graph editor for workflow YAML (private, alpha) | app | [README](packages/workflow-dashboard/README.md) |
Managed with **bun workspace** using the `workspace:*` protocol.
## Quick Start
```bash
# 1. Configure provider, model, and default agent
uwf setup
# Install dependencies
bun install
# 2. Register a workflow from YAML
uwf workflow add examples/solve-issue.yaml
# Build all packages
bun run build
# 3. Start a thread (creates head pointer; does not execute)
uwf thread start solve-issue -p "Fix the login redirect bug"
# Register a workflow bundle
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
# 4. Execute steps (one at a time, until done)
uwf thread exec <thread-id>
# Run a workflow
uncaged-workflow run solve-issue --prompt "Fix bug #42"
```
Use `-c, --count <number>` on `thread exec` to run multiple steps in one invocation. Override the agent with `--agent <cmd>`.
## CLI Usage
## CLI Reference
```bash
uncaged-workflow # Print full command usage (exits with status 1)
uncaged-workflow workflow list # List registered workflows
uncaged-workflow run <name> # Start a workflow thread
uncaged-workflow thread list # List all threads
uncaged-workflow thread show <id> # Inspect a thread
uncaged-workflow skill # Agent-consumable reference docs
```
Global options: `-V, --version`, `--format <json|yaml>`, `-h, --help`.
| Group | Commands |
|-------|----------|
| **thread** | `start`, `exec`, `show`, `list`, `stop`, `cancel`, `read` |
| **step** | `list`, `show`, `fork` |
| **workflow** | `add`, `show`, `list` |
| **cas** | `get`, `put`, `put-text`, `has`, `refs`, `walk`, `reindex`, `schema list`, `schema get` |
| **setup** | Interactive or `--provider`, `--base-url`, `--api-key`, `--model`, `--agent` |
| **skill** | `cli` — print markdown reference of all uwf commands |
| **log** | `list`, `show`, `clean` — process-level debug logs |
Config is stored in `~/.uncaged/workflow/config.yaml`. API keys go in `~/.uncaged/workflow/.env`.
Detailed command usage, options, and examples: [packages/cli-workflow/README.md](packages/cli-workflow/README.md).
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
## Development
```bash
bun install --no-cache # Install dependencies
bun run build # tsc --build (all packages)
bun run check # tsc + biome + lint-log-tags
bun run format # Auto-format with Biome
bun test # Run all tests
bun run check # Biome lint + format check
bun run format # Auto-format with Biome
bun test # Run tests
```
Managed with **bun workspace**. See [CLAUDE.md](CLAUDE.md) for coding conventions.
## Architecture
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
+1 -22
View File
@@ -5,8 +5,6 @@
"**",
"!**/dist",
"!**/node_modules",
"!**/legacy-packages",
"!scripts",
"!packages/workflow/workflow",
"!xiaoju/scripts/bundle.ts"
]
@@ -17,15 +15,6 @@
"indentWidth": 2,
"lineWidth": 100
},
"css": {
"parser": {
"cssModules": true,
"tailwindDirectives": true
},
"linter": {
"enabled": false
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
@@ -47,7 +36,7 @@
}
},
{
"includes": ["**/*.d.ts", "**/vitest.config.*"],
"includes": ["**/*.d.ts"],
"linter": {
"rules": {
"style": {
@@ -55,16 +44,6 @@
}
}
}
},
{
"includes": ["**/cli.ts", "**/setup.ts"],
"linter": {
"rules": {
"suspicious": {
"noConsole": "off"
}
}
}
}
],
"linter": {
+16
View File
@@ -0,0 +1,16 @@
import { createCursorAgent } from "./packages/workflow-agent-cursor/src/index.js";
import { createWorkflow } from "./packages/workflow-runtime/src/create-workflow.js";
import {
buildDevelopDescriptor,
developWorkflowDefinition,
} from "./packages/workflow-template-develop/src/index.js";
const agent = createCursorAgent({
command: "/home/azureuser/.local/bin/cursor-agent",
model: "auto",
timeout: 300_000,
workspace: null,
});
export const descriptor = buildDevelopDescriptor();
export const run = createWorkflow(developWorkflowDefinition, { adapter: agent, overrides: null });
+179 -406
View File
@@ -1,495 +1,268 @@
# Workflow Engine — Architecture
# Uncaged workflow — Architecture
**Last updated:** 2026-05-19
**Last updated:** 2026-05-09
---
## Overview
A stateless workflow engine driven by a single-step CLI. Workflows are YAML definitions stored as CAS nodes; threads are immutable chains of CAS-linked step nodes. No daemon — each `uwf thread step` invocation runs one moderator→agent→extract cycle and exits.
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
The implementation lives in **6** active packages under `packages/`, plus two external CAS packages (`@uncaged/json-cas`, `@uncaged/json-cas-fs`). Legacy packages reside in `legacy-packages/` and are not part of the active stack.
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
## Package map
Grouped by responsibility (npm name → folder).
| Layer | Package | One-line role |
|-------|---------|---------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types (`WorkflowPayload`, `StepNodePayload`, `ModeratorContext`, `WorkflowConfig`, etc.). No runtime deps beyond `@uncaged/json-cas-fs`. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Crockford Base32, ULID generation, `createLogger`, frontmatter parsing/validation. |
| Moderator | `@uncaged/workflow-moderator``workflow-moderator` | JSONata-based graph evaluator: given a `WorkflowPayload` and `ModeratorContext`, returns the next role or `$END`. |
| Agent framework | `@uncaged/workflow-agent-kit``workflow-agent-kit` | `createAgent` entrypoint factory, context builder, frontmatter fast-path extractor, LLM extract fallback, output format instruction builder. |
| Agent: Hermes | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `uwf-hermes` CLI binary — spawns `hermes chat`, pipes prompt, captures session detail. |
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uwf` binary — thread lifecycle, workflow registry, CAS inspection, setup. |
|-------|---------|----------------|
| Contract | `@uncaged/workflow-protocol``workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
| Author API | `@uncaged/workflow-runtime``workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
| Shared infra | `@uncaged/workflow-util``workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
| LLM plumbing | `@uncaged/workflow-reactor``workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
| CAS | `@uncaged/workflow-cas``workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
| Registry / bundles | `@uncaged/workflow-register``workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
| Engine | `@uncaged/workflow-execute``workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
| CLI | `@uncaged/cli-workflow``cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
| Agent adapters | `@uncaged/workflow-agent-cursor``workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
| | `@uncaged/workflow-agent-hermes``workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
| | `@uncaged/workflow-agent-llm``workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
| Agent shared | `@uncaged/workflow-util-agent``workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
| Templates | `@uncaged/workflow-template-develop``workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
| | `@uncaged/workflow-template-solve-issue``workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
| Dashboard | `@uncaged/workflow-dashboard``workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
### External dependencies
## Dependency graph (workspace packages)
| Package | Role |
|---------|------|
| `@uncaged/json-cas` | Content-addressed store API, XXH64 hashing, JSON Schema registration and validation. |
| `@uncaged/json-cas-fs` | Filesystem backend for `json-cas`. |
| `jsonata` | JSONata expression evaluator (used by `workflow-moderator`). |
| `commander` | CLI argument parsing (used by `cli-workflow`). |
| `dotenv` | Loads `.env` files for API keys. |
| `yaml` | YAML parse/stringify. |
## Dependency graph
Bottom-up layering for the execution stack:
```mermaid
flowchart BT
subgraph External
jcas["@uncaged/json-cas"]
jcasfs["@uncaged/json-cas-fs"]
end
subgraph L0["Layer 0 — contract"]
protocol["@uncaged/workflow-protocol"]
end
subgraph L1["Layer 1 — shared"]
subgraph L1["Layer 1 — on protocol"]
runtime["@uncaged/workflow-runtime"]
util["@uncaged/workflow-util"]
moderator["@uncaged/workflow-moderator"]
reactor["@uncaged/workflow-reactor"]
end
subgraph L2["Layer 2 — agent framework"]
kit["@uncaged/workflow-agent-kit"]
subgraph L2["Layer 2 — protocol + util"]
cas["@uncaged/workflow-cas"]
register["@uncaged/workflow-register"]
end
subgraph L3["Layer 3 — agent implementations"]
hermes["@uncaged/workflow-agent-hermes"]
subgraph L3["Layer 3 — engine"]
execute["@uncaged/workflow-execute"]
end
subgraph L4["Layer 4 — CLI"]
cli["@uncaged/cli-workflow"]
end
protocol --> jcasfs
runtime --> protocol
util --> protocol
moderator --> protocol
kit --> protocol
kit --> util
kit --> jcas
kit --> jcasfs
hermes --> kit
hermes --> jcas
reactor --> protocol
cas --> protocol
cas --> util
register --> protocol
register --> util
execute --> protocol
execute --> runtime
execute --> util
execute --> cas
execute --> reactor
execute --> register
cli --> protocol
cli --> util
cli --> kit
cli --> moderator
cli --> jcas
cli --> jcasfs
cli --> cas
cli --> execute
cli --> register
cli --> runtime
```
## Workflow definition
**Adjacent consumers** (not in the main CLI stack):
Workflows are **YAML files** (not ESM bundles). `uwf workflow put <file.yaml>` parses the YAML, registers output schemas as JSON Schema CAS nodes, and stores the `WorkflowPayload` as a CAS node.
- `@uncaged/workflow-util-agent``@uncaged/workflow-runtime`
- `@uncaged/workflow-agent-llm``@uncaged/workflow-runtime`
- `@uncaged/workflow-agent-cursor``@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
- `@uncaged/workflow-agent-hermes``@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
- `@uncaged/workflow-template-develop``@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
- `@uncaged/workflow-template-solve-issue``@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
Example (`examples/solve-issue.yaml`):
## Package roles (detail)
```yaml
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. Analyze the issue and create a step-by-step plan."
capabilities:
- issue-analysis
- planning
procedure: "Analyze the issue and create a detailed, actionable implementation plan."
output: "Output the plan summary and list of concrete steps."
meta:
type: object
properties:
plan: { type: string }
steps: { type: array, items: { type: string } }
required: [plan, steps]
developer:
description: "Implements code changes"
goal: "You are a developer agent. Implement the plan."
capabilities:
- file-edit
- shell
procedure: "Implement the plan. Write code, tests, and ensure existing tests pass."
output: "List all files changed and provide a summary of the implementation."
meta:
type: object
properties:
filesChanged: { type: array, items: { type: string } }
summary: { type: string }
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. Review the implementation."
capabilities:
- code-review
procedure: "Review the implementation against the plan."
output: "Approve or reject with detailed comments."
meta:
type: object
properties:
approved: { type: boolean }
comments: { type: string }
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
planner:
- role: "developer"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
Key properties:
- **`roles`** — inline role definitions; each `meta` is a JSON Schema (stored as its own CAS node on registration)
- **`conditions`** — named JSONata expressions evaluated against the `ModeratorContext`
- **`graph`** — `Record<Role | "$START", Transition[]>` — first matching transition wins; `condition: null` = fallback
- **No agent binding** — agent selection is a deployment concern, configured in `config.yaml`
- **No Zod** — all schemas are JSON Schema, validated through `@uncaged/json-cas`
- **`workflow-protocol`** — Pure types (`WorkflowFn`, contexts, `CasStore` interface, descriptor shapes), `START` / `END`, `ok` / `err`. Depends only on peer `zod` for schema-related types in signatures.
- **`workflow-runtime`** — Workflow author surface: `createWorkflow` from `src/create-workflow.js`, re-exports protocol types/constants used when authoring bundles.
- **`workflow-util`** — Cross-cutting utilities: Crockford Base32, ULID, `createLogger`, `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`, ref normalization; re-exports `ok`/`err` from protocol.
- **`workflow-cas`** — Filesystem CAS (`createCasStore`), `hashString` / `hashWorkflowBundleBytes`, Merkle node serialization and helpers (`merkle.js`).
- **`workflow-register`** — Bundle pipeline (`validateWorkflowBundle`, `extractBundleExports`, descriptor builders), registry YAML read/write, `resolveModel` / `splitProviderModelRef`.
- **`workflow-execute`** — `executeThread`, supervisor/worker wiring (`engine/`), fork/GC/pause gate, `createExtract` + LLM extract helpers (`extract/`), `workflowAsAgent`. Imports `@uncaged/workflow-reactor` for LLM-backed extract/supervisor paths (`extract-fn.ts`, `supervisor.ts`).
- **`workflow-reactor`** — `createLlmFn`, `createThreadReactor`, and thread tool-invocation types — consumed by `workflow-execute`.
- **`cli-workflow`** — CLI commands and HTTP/dashboard-related wiring (`hono`, `yaml`); composes register + execute + CAS + util.
- **`workflow-agent-*`** — Replaceable `AgentFn` implementations (Cursor / Hermes CLIs, or HTTP LLM).
- **`workflow-util-agent`** — Shared prompt assembly and subprocess spawning for CLI agents.
- **`workflow-template-*`** — Concrete `WorkflowDefinition` graphs + Zod role schemas + descriptor builders for publishing bundles.
- **`workflow-dashboard`** — Standalone React UI; no published library entry matching `src/index.ts`.
## Three-phase engine loop
Each `uwf thread step` runs exactly one cycle: moderator → agent → extract. The CLI orchestrates this in `packages/cli-workflow/src/commands/thread.ts` (`cmdThreadStep`).
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
```
┌─→ Phase 1: MODERATOR
Input: WorkflowPayload + ModeratorContext { start, steps[] }
Engine: JSONata conditions evaluated against the graph
│ Output: next role name | $END
Context: ModeratorContext { threadId, depth, start, steps }
Action: moderator(ctx) → role name | END
│ Phase 2: AGENT
Input: thread-id + role (via argv)
Engine: agent-kit builds context from CAS chain, prepends
│ output format instruction to system prompt, spawns agent
│ Output: raw string (frontmatter markdown)
Context: AgentContext = ModeratorCtx + { currentRole: { name, systemPrompt } }
Action: agent(ctx) → raw string
│ Phase 3: EXTRACT
Input: raw agent output + role's meta schema
Engine: two-layer extract (frontmatter fast path → LLM fallback)
│ Output: CasRef to structured output node
│ Phase 3: EXTRACTOR
Context: ExtractContext = AgentCtx + { agentContent }
Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
Persist: StepNode { start, prev, role, output, detail, agent }
Update: threads.yaml head pointer
└─────────────────────────────────────────────────────────────────
Merge: RoleStep { role, contentHash, meta, refs, timestamp }
Append to steps
└─────────────────────────────────────────────────────┘
```
### Context types
### Context types (progressive)
Defined in `packages/workflow-protocol/src/types.ts`:
```typescript
type StepContext = {
role: string;
output: unknown; // CAS node payload, expanded (not hash)
detail: CasRef;
agent: string;
};
type ModeratorContext = {
start: StartNodePayload; // { workflow: CasRef, prompt: string }
steps: StepContext[]; // chronological, oldest first
};
type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
type ModeratorContext<M> = ThreadContext<M>;
type AgentContext<M> = ModeratorContext<M> & {
currentRole: { name: string; systemPrompt: string };
};
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
```
### Key properties
- **Moderator** — pure JSONata evaluation; no LLM call, no I/O beyond CAS reads. Evaluates `workflow.graph[currentRole]` transitions in order, returns first match.
- **Agent** — receives `AgentContext` with thread history + role system prompt + output format instruction. Raw output is frontmatter markdown.
- **Extractor** — two-layer: tries frontmatter fast-path first (zero LLM cost), falls back to LLM extract if frontmatter is absent or invalid.
- **Stateless** — each `uwf thread step` is an atomic, self-contained operation. No in-memory state between steps.
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
## Agent CLI protocol
## Agent information sources
Each agent is an external command invoked by `uwf thread step`:
An agent has exactly three information sources:
```bash
<agent-cmd> <thread-id> <role>
1. **Prior knowledge** — LLM training, agent memory, agent skills
2. **Thread context**`AgentContext` (`start`, `steps`, `currentRole`)
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
No hidden environment parameters. If an agent needs something (like a workspace path), it obtains it via `ExtractFn` (e.g. Cursor agent).
## Bundle contract
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
```typescript
export const descriptor: WorkflowDescriptor;
export const run: WorkflowFn;
type WorkflowFn = (
thread: ThreadContext,
runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
```
Contract:
1. `uwf thread step` determines the next role via the moderator
2. Agent CLI is spawned with `(thread-id, role)` as positional args
3. `workflow-agent-kit` (`createAgent`) handles the boilerplate:
- Parses argv
- Loads `.env` from storage root
- Builds `AgentContext` by walking the CAS chain from `threads.yaml` head
- Resolves the role's `meta` schema and builds `outputFormatInstruction`
- Calls the agent's `run` function
- Runs two-layer extract on the raw output
- Writes `StepNode` to CAS (output + detail + prev link)
- Prints the new `StepNode` CAS hash to stdout
4. `uwf thread step` reads stdout, updates `threads.yaml` head pointer, re-evaluates moderator for `done`
5. Exit 0 = success, non-zero = failure
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
Agent resolution priority: `--agent` CLI override → `config.yaml` per-workflow/role override → `config.yaml` `defaultAgent`.
### Constraints
## Agent output format: frontmatter markdown (RFC #351)
- Single `.esm.js` file
- No dynamic `import()` in bundles (loader exempt in engine)
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
- XXH64 hash (Crockford Base32) = version ID
Agents produce **frontmatter markdown** — YAML frontmatter for structured meta, followed by a markdown body for content:
### Why AsyncGenerator?
```markdown
---
status: done
next: reviewer
confidence: 0.9
artifacts:
- src/auth.ts
scope: role
---
## Implementation
Fixed the login redirect by updating the auth middleware...
```
The `outputFormatInstruction` (built by `buildOutputFormatInstruction` in `workflow-agent-kit`) is prepended to the role's system prompt, so the deliverable format is the first thing the agent sees. It lists the expected frontmatter fields derived from the role's `meta` JSON Schema.
## Two-layer extract
Structured output extraction uses a two-layer strategy (`workflow-agent-kit`):
### Layer 1: frontmatter fast path (`frontmatter.ts`)
1. Parse YAML frontmatter from raw agent output (`parseFrontmatterMarkdown`)
2. Validate required fields (`validateFrontmatter`)
3. Build a candidate object from frontmatter fields (`status`, `next`, `confidence`, `artifacts`, `scope`)
4. `store.put()` the candidate against the role's `meta` schema
5. Validate with `json-cas` schema validation
6. If valid → return `outputHash` (zero LLM cost)
### Layer 2: LLM extract fallback (`extract.ts`)
If the fast path returns `null` (no frontmatter, invalid, or doesn't satisfy schema):
1. Resolve extract model alias from config (`modelOverrides.extract``models.extract``defaultModel`)
2. Call OpenAI-compatible chat completion with JSON mode
3. System prompt: "Extract structured data matching this JSON Schema: ..."
4. User message: the raw agent output
5. Parse response, `store.put()`, validate
6. Return `outputHash`
## Prompt injection
`workflow-agent-kit` prepends two pieces of context to the agent's system prompt:
1. **Deliverable format instruction** — generated from the role's `meta` schema, tells the agent exactly what frontmatter fields to produce and the expected format
2. **Scope constraint** — "Focus exclusively on YOUR role's deliverable. Do not perform actions outside your role's scope."
This ensures agents produce parseable frontmatter output without requiring per-agent format knowledge.
## CAS node types
### Workflow
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema node
conditions:
notApproved:
description: "Reviewer rejected"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null
```
### StartNode
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
### StepNode
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode
prev: "2MXBG6PN4A8JR" # cas_ref → previous StepNode (null for first step)
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → structured output (validated against meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → execution detail (raw turns, session data)
agent: "uwf-hermes" # agent command used (plain string)
```
### Chain structure
```
threads.yaml: { "01J7K9...4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → Workflow (CAS)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── prev ──→ StepNode (step 1)
│ │ └── prev: null
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(session turns)
└── agent: "uwf-hermes"
```
- Each `yield` lets `workflow-execute` persist state, CAS rows, and enforce pause/abort
- `return` supplies `WorkflowCompletion`
- Fork replays historical steps into a new thread context
- Bundle does not import the engine — only protocol/runtime types at build time
## Storage layout
```
~/.uncaged/workflow/
├── cas/ # json-cas filesystem store (all CAS nodes)
├── config.yaml # Provider, model, agent configuration
├── threads.yaml # Active thread head pointers: threadId → CasRef
├── history.jsonl # Archived thread records
├── registry.yaml # Workflow name → CAS hash mapping
└── .env # API keys (loaded by dotenv)
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
├── bundles/
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
│ └── history/
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
├── logs/ # One folder per bundle hash
│ └── C9NMV6V2TQT81/
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
│ └── 01KQXKW…YG.info.jsonl # Debug log
└── workflow.yaml # Registry
```
### Mutable state
Only three files carry mutable state:
| File | Contents |
|------|----------|
| `threads.yaml` | `Record<ThreadId, CasRef>` — maps active thread IDs to head node hash |
| `history.jsonl` | Append-only log of completed threads (`thread`, `workflow`, `head`, `completedAt`) |
| `registry.yaml` | Workflow name → current CAS hash |
Everything else is immutable CAS content.
### ID encoding: Crockford Base32
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
- CAS hash: XXH64 → 13-char Crockford Base32
- Thread ID: ULID → 26-char Crockford Base32 (10 timestamp + 16 random)
- Bundle hash: XXH64 → 13-char
- Thread ID: ULID → 26-char (10 timestamp + 16 random)
### Config (`config.yaml`)
### Registry (`workflow.yaml`)
```yaml
providers:
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
### Thread storage (CAS + index)
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
```jsonc
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
```
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → code location.
## Execution model
- **No daemon.** `uncaged-workflow run <name>` starts a worker process (`workflow-execute` worker entry via `getWorkerHostScriptPath`)
- Threads share bundle-scoped workers as implemented in CLI/engine
- Pause/resume/abort via engine IPC and pause gate (`createThreadPauseGate`)
## CLI commands
Binary: `uwf`
### Thread commands
| Command | Description |
|---------|-------------|
| `uwf thread start <workflow> -p <prompt>` | Create a thread (StartNode → CAS, head → threads.yaml). No execution. |
| `uwf thread step <thread-id> [--agent <cmd>]` | Execute one moderator→agent→extract cycle. |
| `uwf thread show <thread-id>` | Show thread head pointer and done status. |
| `uwf thread list [--all]` | List active threads (`--all` includes archived). |
| `uwf thread steps <thread-id>` | List all steps in chronological order. |
| `uwf thread read <thread-id> [--quota <chars>] [--before <hash>]` | Render thread as human-readable markdown. |
| `uwf thread fork <step-hash>` | Fork a thread from a specific CAS node. |
| `uwf thread step-details <step-hash>` | Dump full detail node as YAML. |
| `uwf thread kill <thread-id>` | Terminate and archive a thread. |
### Workflow commands
| Command | Description |
|---------|-------------|
| `uwf workflow put <file.yaml>` | Register a workflow from YAML definition. |
| `uwf workflow show <id>` | Show workflow by name or CAS hash. |
| `uwf workflow list` | List registered workflows. |
### CAS commands
| Command | Description |
|---------|-------------|
| `uwf cas get <hash>` | Read a CAS node. |
| `uwf cas put <type-hash> <data>` | Store a node, print its hash. |
| `uwf cas has <hash>` | Check if a hash exists. |
| `uwf cas refs <hash>` | List direct CAS references. |
| `uwf cas walk <hash>` | Recursive traversal from a node. |
| `uwf cas reindex` | Rebuild type index from all nodes. |
| `uwf cas schema list` | List registered schemas. |
| `uwf cas schema get <hash>` | Show a schema by type hash. |
### Setup
| Command | Description |
|---------|-------------|
| `uwf setup [--provider --base-url --api-key --model --agent]` | Configure provider/model/agent (interactive if no flags). |
## Toolchain
| Tool | Purpose |
|------|---------|
| **bun** | Package manager + runtime |
| **TypeScript** | Type checking (strict mode) |
| **Biome** | Lint + format |
| **vitest** | Test runner |
| Priority | Command | Description |
|----------|---------|-------------|
| P1 | `add <name> <file.esm.js>` | Register a bundle |
| P1 | `list` | List registered workflows |
| P1 | `show <name>` | Show workflow details |
| P1 | `remove <name>` | Remove a workflow |
| P1 | `run <name> [--prompt] [--max-rounds]` | Start a thread |
| P1 | `threads [name]` | List threads |
| P1 | `thread <id>` | Show thread state |
| P1 | `thread rm <id>` | Delete a thread |
| P1 | `ps` | List running threads |
| P1 | `kill <thread-id>` | Terminate a running thread |
| P2 | `history <name>` | Show version history |
| P2 | `rollback <name> [hash]` | Switch to a previous version |
| P2 | `pause <thread-id>` | Pause a running thread |
| P2 | `resume <thread-id>` | Resume a paused thread |
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
## Design decisions
| Decision | Rationale |
|----------|-----------|
| **YAML workflow definitions** | Human-readable, versionable, no build step required. JSON Schema inline in YAML, registered as CAS nodes on `workflow put`. |
| **Stateless single-step CLI** | Each `uwf thread step` is atomic — no in-memory state, no daemon, no long-running process. OS handles lifecycle. |
| **CAS-backed thread state** | Immutable linked nodes enable fork, replay, and GC without copying data. Content-addressed deduplication across threads. |
| **JSONata moderator** | Declarative condition expressions evaluated against thread history. No LLM cost for routing decisions. |
| **Frontmatter markdown output** | Agents produce structured meta (YAML frontmatter) alongside free-form content (markdown body). Enables zero-cost extraction when frontmatter is well-formed. |
| **Two-layer extract** | Fast path avoids LLM calls when agents follow the format; LLM fallback handles messy output gracefully. |
| **Prompt injection for format** | Output format instruction prepended to system prompt ensures agents produce parseable output without per-agent configuration. |
| **JSON Schema (not Zod)** | Schemas are CAS-native data — storable, hashable, validatable through `json-cas`. No code generation, no runtime library dependency. |
| **Agent as external command** | Agents are independent CLI binaries (`uwf-hermes`, `uwf-cursor`). Swappable per workflow/role via config. No tight coupling to the engine. |
| **No daemon** | Process starts, does one step, exits. Simpler failure model, no connection management. |
| **Crockford Base32** | Filesystem-safe, case-insensitive, readable, compact. |
| **Role = pure data** | Decouples definition from execution; same role with different agents |
| **Agent bound at runtime** | `WorkflowDefinition` is reusable; agent choice is deployment concern |
| **Three-phase context** | Each phase sees only what it needs; types live in `workflow-protocol` |
| **`WorkflowRuntime.extract` + CAS `contentHash`** | Large agent bodies deduplicated globally; Merkle roots summarize threads |
| **`workflow-reactor` split** | LLM tool-calling loop isolated from filesystem/registry concerns |
| **Single-file ESM** | Hash = version, self-contained bundle |
| **No daemon** | OS handles process lifecycle |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
-779
View File
@@ -1,779 +0,0 @@
# Built-in Role Agent 调研
## 目标
实现一个内置的 role agent(暂称 `uwf-builtin`),不依赖 hermes/openclaw 等外部 agent 进程。
直接使用 workflow config 中配置的 model,自己实现 agent run loop 和关键 toolkit。
---
## 关键问题
### Q1: Agent 接口协议
现有 agent 是怎么被 CLI 调用的?输入(argv、环境变量)和输出(stdout、CAS)格式是什么?
**调研要点:**
- `cli-workflow``spawnAgent` 的完整实现
- AgentConfig 类型定义
- agent 进程的 exit code 约定
- 环境变量传递(UWF_STORAGE_ROOT 等)
**答案:**
#### 调用链
`uwf thread step``cmdThreadStepOnce` → moderator 求值下一 role → `resolveAgentConfig``spawnAgent`
#### AgentConfig 类型
```146:149:packages/workflow-protocol/src/types.ts
export type AgentConfig = {
command: string;
args: string[];
};
```
在 `config.yaml` 的 `agents` 段注册,例如 `hermes: { command: "uwf-hermes", args: [] }`。
#### spawnAgent 行为
```627:653:packages/cli-workflow/src/commands/thread.ts
function spawnAgent(agent: AgentConfig, threadId: ThreadId, role: string): CasRef {
const argv = [...agent.args, threadId, role];
let stdout: string;
try {
stdout = execFileSync(agent.command, argv, {
encoding: "utf8",
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
} catch (e) {
// ... stderr 拼进 fail 消息
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
```
| 项目 | 约定 |
|------|------|
| **argv** | `[...agent.args, <thread-id>, <role>]`,即 `process.argv[2]`=threadId,`process.argv[3]`=role(与 `createAgent` 的 `parseArgv` 一致) |
| **stdin** | 忽略 |
| **stdout** | 纯文本,**最后一行**必须是新 `StepNode` 的 CAS hash(13 字符 Crockford Base32) |
| **stderr** | 失败时 CLI 会附带 stderr;成功时无约定 |
| **exit code** | `0` = 成功;非 0 时 `execFileSync` 抛错,step 失败 |
| **环境变量** | 继承父进程 `process.env`(含 storage root、API key 等) |
| **链头更新** | **不由 agent 负责**;agent 只写 CAS StepNode,CLI 在拿到 stdout hash 后更新 `threads.yaml` |
Agent 解析优先级(`resolveAgentConfig`):
1. CLI `--agent` override(整段 command + args 字符串)
2. `config.agentOverrides[workflow.name][role]`
3. `config.defaultAgent`
#### 环境变量:Storage Root
文档中写的 `UWF_STORAGE_ROOT` **在当前代码中不存在**。实际优先级(`workflow-agent-kit` / `cli-workflow` 一致):
```33:43:packages/workflow-agent-kit/src/storage.ts
export function resolveStorageRoot(): string {
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
if (internal !== undefined && internal !== "") {
return internal;
}
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
if (userOverride !== undefined && userOverride !== "") {
return userOverride;
}
return getDefaultStorageRoot();
}
```
Agent 子进程通过继承的 `process.env` 与父 CLI 共享同一 storage root;`createAgent` 内还会 `loadDotenv({ path: getEnvPath(storageRoot) })` 加载 `~/.uncaged/workflow/.env`。
#### Agent 侧职责(设计文档 + 实现)
- 读 `threads.yaml` 链头,构建 context,执行 role
- 将 `StepNode` 写入 CAS(`output` / `detail` / `agent` / `prev` / `start`)
- stdout 打印 step hash
- **不**更新 `threads.yaml`
---
### Q2: createAgent 工厂
workflow-agent-kit 的 `createAgent` 做了什么?它的完整生命周期是什么?
**调研要点:**
- `AgentOptions` 类型的 `run` 和 `continue` 回调签名
- `AgentRunResult` 的完整定义
- retry 逻辑(frontmatter 校验失败后的重试机制)
- `persistStep` 写入 CAS 的 StepNode 结构
**答案:**
#### 类型定义
```4:35:packages/workflow-agent-kit/src/types.ts
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
outputFormatInstruction: string;
};
export type AgentRunResult = {
output: string;
detailHash: CasRef;
sessionId: string;
};
export type AgentContinueFn = (
sessionId: string,
message: string,
store: AgentContext["store"],
) => Promise<AgentRunResult>;
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
continue: AgentContinueFn;
};
```
- **`run(ctx)`**:首次执行,返回原始 agent 文本 `output`、审计用 `detailHash`、用于续聊的 `sessionId`。
- **`continue(sessionId, message, store)`**:在同一 session 上追加用户消息(用于 frontmatter 纠错),再次返回 `AgentRunResult`。
`createAgent(options)` 返回 `() => Promise<void>`,作为 agent CLI 的 `main`(见 `uwf-hermes` 的 `cli.ts`)。
#### 生命周期(按执行顺序)
```101:152:packages/workflow-agent-kit/src/run.ts
export function createAgent(options: AgentOptions): () => Promise<void> {
return async function main(): Promise<void> {
const { threadId, role } = parseArgv(process.argv);
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const ctx = await buildContextWithMeta(threadId, role);
// 1. 校验 role 存在
// 2. 从 CAS 取 frontmatter JSON Schema → buildOutputFormatInstruction → ctx.outputFormatInstruction
let agentResult = await options.run(ctx);
let outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
for (let retry = 0; retry < MAX_FRONTMATTER_RETRIES && outputHash === null; retry++) {
const correctionMessage = "Your previous response did not contain valid YAML frontmatter...";
agentResult = await options.continue(agentResult.sessionId, correctionMessage, ctx.meta.store);
outputHash = await tryExtractOutput(agentResult.output, roleDef.frontmatter, ctx);
}
if (outputHash === null) { fail(...); }
const stepHash = await persistStep({ ctx, outputHash, detailHash: agentResult.detailHash, agentName });
process.stdout.write(`${stepHash}\n`);
};
}
```
| 阶段 | 行为 |
|------|------|
| 解析 argv | `argv[2]=threadId`, `argv[3]=role`,缺失则 `stderr` + `exit(1)` |
| Context | `buildContextWithMeta` + 可选 `outputFormatInstruction` |
| Run | `options.run(ctx)` |
| Extract | **仅** `tryFrontmatterFastPath`(见 Q4);**不**调用 `extract()` LLM fallback |
| Retry | 最多 `MAX_FRONTMATTER_RETRIES = 2` 次 `continue` + 再试 fast-path |
| Persist | `persistStep` → `writeStepNode` |
| 输出 | stdout 一行 step CAS hash |
#### StepNode 写入结构
```44:68:packages/workflow-agent-kit/src/run.ts
async function writeStepNode(options: {
store: AgentStore["store"];
schemas: AgentStore["schemas"];
startHash: CasRef;
prevHash: CasRef | null;
role: string;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const payload: StepNodePayload = {
start: options.startHash,
prev: options.prevHash,
role: options.role,
output: options.outputHash,
detail: options.detailHash,
agent: options.agentName,
};
// store.put(stepNode schema) + validate
}
```
`agentName` 经 `agentLabel(name)` 规范化:已有 `uwf-` 前缀则原样,否则加 `uwf-`(如 `hermes` → `uwf-hermes`)。
`prevHash`:若链头仍是 `StartNode` 则为 `null`,否则为当前 head step hash。
---
### Q3: Context Builder
`buildContextWithMeta` 构建了什么上下文给 agent?
**调研要点:**
- `AgentContext` 完整类型定义(所有字段)
- context 构建过程(CAS chain walk)
- `outputFormatInstruction` 怎么生成的
- role definition 怎么获取(从 workflow YAML)
**答案:**
#### AgentContext 字段
继承 `ModeratorContext`:
```60:68:packages/workflow-protocol/src/types.ts
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
```
```48:51:packages/workflow-protocol/src/types.ts
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
```
```61:63:packages/workflow-protocol/src/types.ts
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
```
`AgentContext` 额外字段:
| 字段 | 类型 | 含义 |
|------|------|------|
| `threadId` | `ThreadId` | 当前线程 |
| `role` | `string` | 本步要执行的角色名 |
| `store` | `Store` | CAS store(读写节点) |
| `workflow` | `WorkflowPayload` | 已从 CAS 加载的 workflow 定义 |
| `outputFormatInstruction` | `string` | 由 `createAgent` 根据 role 的 frontmatter schema 生成;`buildContext*` 初始为 `""` |
`buildContextWithMeta` 还返回 `meta`:
```148:154:packages/workflow-agent-kit/src/context.ts
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
```
#### CAS chain walk
1. 从 `threads.yaml[threadId]` 取 `headHash`
2. `walkChain`:若 head 是 `StartNode`,`stepsNewestFirst=[]`;否则沿 `prev` 收集所有 `StepNode`, newest-first
3. `buildHistory`:反转为时间序,`expandOutput` 把每步 `output` CasRef 展开为 JSON payload(供 prompt / JSONata 使用)
4. `loadWorkflow`:从 `start.workflow` CasRef 加载 `WorkflowPayload`
#### Role definition 来源
- 作者写在 workflow YAML 的 `roles.<name>`(`goal`, `capabilities`, `procedure`, `output`, `frontmatter` 等)
- `uwf workflow put` 时 `frontmatter` 内联 JSON Schema 经 `putSchema` 存入 CAS,workflow 里存的是 **CasRef**
- Agent 运行时:`ctx.workflow.roles[ctx.role]` → `RoleDefinition`
#### outputFormatInstruction
在 `createAgent` 中,若 `getSchema(store, roleDef.frontmatter)` 非空,则:
```typescript
ctx.outputFormatInstruction = buildOutputFormatInstruction(frontmatterSchema);
```
`buildOutputFormatInstruction` 根据 JSON Schema 的 `properties` 生成「必须以 `---` YAML frontmatter 开头」的说明和示例字段列表(见 `build-output-format-instruction.ts`)。
各 agent 实现(Hermes / Claude Code)在组装 prompt 时把该块放在最前,再接 `buildRolePrompt(roleDef)`。
---
### Q4: Extract Pipeline
agent 输出怎么被处理成结构化数据?
**调研要点:**
- frontmatter fast-path 的完整逻辑
- LLM extract fallback 的实现(`extract.ts`)
- frontmatter schema 从哪里来(role 定义里的 `frontmatter` 字段)
- 校验失败时的 correction prompt 是什么
**答案:**
#### Schema 来源
Workflow YAML 中每个 role 的 `frontmatter:` 段是 JSON Schema 对象;注册时:
```66:76:packages/cli-workflow/src/commands/workflow.ts
async function resolveFrontmatterRef(..., frontmatter: unknown): Promise<CasRef> {
// 校验为 JSON Schema → putSchema → 返回 CasRef
}
```
运行时 `roleDef.frontmatter` 即该 schema 的 CAS hash;structured `output` 节点用**同一 schema** 写入 CAS。
#### Frontmatter fast-path(createAgent 实际使用的路径)
```148:195:packages/workflow-agent-kit/src/frontmatter.ts
export async function tryFrontmatterFastPath(
raw: string,
outputSchema: CasRef,
store: Store,
): Promise<FrontmatterFastPathResult | null>
```
流程:
1. `parseFrontmatterMarkdown(raw)` → 标准 agent 字段(`status`, `next`, `confidence`, `artifacts`, `scope`)+ body
2. `validateFrontmatter` 失败 → `null`
3. `getSchema(store, outputSchema)` + `extractSchemaFields` 得到 role 需要的属性名
4. `buildCandidate`:从标准 frontmatter + YAML 原始字段拼出符合 schema 的对象
5. `store.put(outputSchema, candidate)` + `validate` → 成功则 `{ body, outputHash }`
**永不抛错**,失败返回 `null`。
#### LLM extract fallback(已实现但未接入 createAgent)
```135:181:packages/workflow-agent-kit/src/extract.ts
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult>
```
- 模型:`resolveExtractModelAlias(config)` → `modelOverrides.extract` → `models.extract` → `models.default` → `defaultModel`
- HTTP:`POST {baseUrl}/chat/completions`,`response_format: { type: "json_object" }`
- System:要求按 JSON Schema 从 agent 输出提取单个 JSON 对象
- 校验通过后 `store.put(outputSchema, structured)`
**重要:`createAgent` 当前未调用 `extract()`**。fast-path 失败且 2 次 `continue` 仍失败则直接 `fail()`。builtin agent 若希望无 frontmatter 也能跑,需在 kit 或 builtin 层显式接入 `extract()`。
#### Correction prompt(retry)
```125:128:packages/workflow-agent-kit/src/run.ts
const correctionMessage =
"Your previous response did not contain valid YAML frontmatter matching the role schema.\n" +
"You MUST begin your response with a YAML frontmatter block (--- delimited).\n" +
"Please output ONLY the corrected frontmatter block followed by your work.";
```
通过 `options.continue(sessionId, correctionMessage, store)` 发给外部 agent;builtin 需在自有 message 历史里 append 同等语义的 user 消息。
---
### Q5: Model 配置与 LLM 调用
workflow 怎么配置和使用 model?
**调研要点:**
- `WorkflowConfig` 中 providers/models/defaultModel/modelOverrides 的完整定义
- `resolveModel` 函数的实现
- `chatCompletionText` 的实现(OpenAI 兼容 HTTP 客户端)
- 有没有 streaming 支持?tool calling 支持?
**答案:**
#### WorkflowConfig
```136:160:packages/workflow-protocol/src/types.ts
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
export type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
```
示例见 `docs/architecture.md`(`providers` / `models` / `defaultModel` / `modelOverrides.extract`)。
#### resolveModel
```32:50:packages/workflow-agent-kit/src/extract.ts
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
const providerEntry = config.providers[modelEntry.provider];
const apiKey = process.env[providerEntry.apiKeyEnv];
return { baseUrl: providerEntry.baseUrl, apiKey, model: modelEntry.name };
}
```
`ResolvedLlmProvider = { baseUrl, apiKey, model }`。
Extract 专用别名解析:
```18:30:packages/workflow-agent-kit/src/extract.ts
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
return config.modelOverrides?.extract ?? (config.models.extract ? "extract" : config.models.default ? "default" : config.defaultModel);
}
```
**尚无** `modelOverrides` 按 role/workflow 解析 agent 主模型的函数;builtin 首版可用 `config.defaultModel`,扩展时可加 `modelOverrides.agent` 或与 `agentOverrides` 对称的表。
#### chatCompletionText
```87:124:packages/workflow-agent-kit/src/extract.ts
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string>
```
| 能力 | 现状 |
|------|------|
| 协议 | OpenAI 兼容 `POST /chat/completions` |
| Streaming | **无**(一次性 `response.text()`) |
| Tool calling | **无**(无 `tools` / `tool_calls` 字段) |
| 多模态 | **无**(仅 text `content`) |
| Extract 专用 | `response_format: { type: "json_object" }` |
builtin agent 的 run loop 需要**新写**带 `tools` 的 completion 客户端(可放在 `workflow-agent-builtin` 或扩展 `workflow-agent-kit` 的 `llm/` 模块),不能复用当前 `chatCompletionText` 而不改。
---
### Q6: Hermes Agent 参考实现
`uwf-hermes` 是怎么实现 `run` 和 `continue` 的?
**调研要点:**
- prompt 怎么组装的(outputFormatInstruction + rolePrompt + task + history)
- hermes CLI 的调用参数
- session management(resume)
- 输出怎么捕获
**答案:**
#### Prompt 组装
```40:53:packages/workflow-agent-hermes/src/hermes.ts
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const rolePrompt = roleDef !== undefined ? buildRolePrompt(roleDef) : "";
const parts: string[] = [];
if (ctx.outputFormatInstruction !== "") {
parts.push(ctx.outputFormatInstruction, "");
}
parts.push(rolePrompt, "", "## Task", ctx.start.prompt);
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
```
`buildRolePrompt` 生成 `## Goal` / `## Capabilities` / `## Prepare`(含 `generateCliReference()`)/ `## Procedure` / `## Output`。
`buildHistorySummary`:每步 `role`、`JSON.stringify(step.output)`、`agent`。
Hermes 把**整段 prompt 作为单条 user 消息**传给 `hermes chat -q`(无独立 system channel)。
#### Hermes CLI 参数
首次:
```88:97:packages/workflow-agent-hermes/src/hermes.ts
spawnHermes(["chat", "-q", prompt, "--yolo", "--max-turns", "90", "--quiet"]);
```
续聊:
```100:114:packages/workflow-agent-hermes/src/hermes.ts
spawnHermes(["chat", "--resume", sessionId, "-q", message, "--yolo", "--max-turns", "90", "--quiet"]);
```
#### Session
- stdout/stderr 中解析 `session_id: <id>`(`parseSessionIdFromStdout`)
- 会话文件:`~/.hermes/sessions/session_<id>.json`
- `loadHermesSession` → `storeHermesSessionDetail`:每 assistant/tool 消息写成 CAS turn 节点,汇总为 `detail`;**output 文本** = 最后一条非空 `assistant` 的 `content`
#### 与 createAgent 的衔接
```157:164:packages/workflow-agent-hermes/src/hermes.ts
export function createHermesAgent(): () => Promise<void> {
return createAgent({ name: "hermes", run: runHermes, continue: continueHermes });
}
```
`uwf-hermes` 入口:`createHermesAgent()` 即 main。
Claude Code 包(`workflow-agent-claude-code`)结构相同:`buildClaudeCodePrompt` 同构,`claude -p` + `--resume` + JSON stdout 解析。
---
### Q7: Toolkit 需求分析
要实现一个自给自足的 agent,最少需要哪些 tool?
**调研要点:**
- 现有 workflow example(solve-issue.yaml)里 role 都做什么任务
- hermes agent 在 workflow 场景下常用哪些 tool
- 哪些 tool 是 agent loop 必须的(如 file read/write、shell exec、web fetch)
**答案:**
#### solve-issue.yaml 角色能力
| Role | capabilities | 隐含需求 |
|------|----------------|----------|
| planner | issue-analysis, planning | 读上下文/仓库、总结,通常不需写代码 |
| developer | file-edit, shell, testing | **读文件、写文件、执行命令** |
| reviewer | code-review, static-analysis | 读 diff/文件、静态分析(可读+可选 shell) |
#### Hermes 侧
Hermes 自带完整 agent runtime(`--yolo`、max-turns),tool 集由 Hermes 项目定义,workflow 不配置。从 session JSON 可见 `tool_calls` 被记入 detail,常见包括文件与 shell 类工具。
#### Builtin 最小 toolkit 建议
| 优先级 | Tool | 用途 |
|--------|------|------|
| P0 | `read_file` | 读仓库/配置/issue 上下文 |
| P0 | `write_file` / `edit_file` | developer 改代码 |
| P0 | `run_command` | 测试、构建、git(需 cwd + timeout + 输出截断) |
| P1 | `list_dir` / `glob` | 导航代码库 |
| P1 | `grep` | 搜索符号/引用 |
| P2 | `fetch_url` | 查文档(planner 偶尔需要) |
**不需要**在 builtin 里实现 moderator / workflow 路由工具——仍由 `uwf thread step` + JSONata 负责。
#### Agent loop 必须能力
1. 多轮 LLM 调用 + **OpenAI-style tool_calls** 解析与执行
2. 将 tool 结果 append 回 messages
3. 终止条件:模型不再请求 tool,或达到 `maxTurns`
4. 最终响应须含合法 YAML frontmatter(满足 Q4),供 `createAgent` fast-path
---
## 方案草案
(调研完成后基于以上答案撰写)
### 架构设计
```mermaid
flowchart TB
subgraph cli ["cli-workflow"]
Step["uwf thread step"]
Spawn["spawnAgent(uwf-builtin, threadId, role)"]
Step --> Spawn
end
subgraph builtin_pkg ["@uncaged/workflow-agent-builtin"]
Main["createBuiltinAgent() = createAgent({...})"]
Prompt["buildBuiltinPrompt(ctx)"]
Loop["runBuiltinLoop(provider, messages, tools)"]
Tools["Toolkit: read/write/exec/..."]
Detail["storeBuiltinDetail(turns)"]
Main --> Prompt
Main --> Loop
Loop --> Tools
Loop --> Detail
end
subgraph kit ["workflow-agent-kit"]
Ctx["buildContextWithMeta"]
FM["tryFrontmatterFastPath"]
Persist["persistStep"]
Ctx --> Main
Main --> FM
FM --> Persist
end
subgraph cas ["CAS / config"]
Config["config.yaml models/providers"]
CAS["cas/ + threads.yaml"]
end
Spawn --> Main
Config --> Loop
CAS --> Ctx
Persist --> CAS
Spawn -->|"stdout: step hash"| Step
```
**新包**:`packages/workflow-agent-builtin`,bin `uwf-builtin`,仅依赖 `workflow-agent-kit`、`workflow-protocol`、`workflow-util`(可选 `@uncaged/json-cas` 写 detail schema)。
**分层**:
| 层 | 职责 |
|----|------|
| `createAgent`(kit) | argv、context、frontmatter extract、StepNode、stdout 协议 — **不变** |
| `builtin/agent.ts` | `run` / `continue` 实现 |
| `builtin/llm.ts` | OpenAI 兼容 chat + tools(可后续抽到 kit) |
| `builtin/tools/*.ts` | 各 tool 的 JSON Schema + handler |
| `builtin/prompt.ts` | 复用 Hermes 的 prompt 拼接逻辑(或抽到 kit 的 `buildAgentPrompt`) |
| `builtin/detail.ts` | 类似 Hermes:每轮 assistant/tool 写入 CAS detail |
**配置集成**:
```yaml
agents:
builtin:
command: "uwf-builtin"
args: []
defaultAgent: "builtin" # 或 agentOverrides 按 role 指定
```
模型:首版 `resolveModel(config, config.defaultModel)`;后续可增加 `modelOverrides.agent` 或 per-role 映射。
---
### Agent Run Loop
伪代码(单次 `run(ctx)`):
```
1. provider ← resolveModel(loadWorkflowConfig(), defaultModel)
2. system ← buildBuiltinPrompt(ctx) // outputFormatInstruction + buildRolePrompt + Task + History
3. messages ← [{ role: "system", content: system }]
4. sessionId ← newULID() // 内存或临时目录,供 continue 使用
5. turns ← []
6. for turn in 1..MAX_TURNS:
response ← chatCompletionWithTools(provider, messages, TOOL_DEFINITIONS)
record assistant message + tool_calls in turns
if response has no tool_calls:
finalText ← response.content
break
for each tool_call:
result ← executeTool(tool_call, { cwd: process.cwd() })
messages.push tool result
record in turns
7. if no finalText with valid frontmatter after loop:
optionally one-shot "finalize" message without tools
8. detailHash ← storeBuiltinDetail(store, sessionId, turns, metadata)
9. return { output: finalText, detailHash, sessionId }
```
**`continue(sessionId, message, store)`**:
- 从内存/磁盘恢复 `messages` + `turns`
- `messages.push({ role: "user", content: message })`(correction 或续聊)
- 从步骤 6 继续,步数上限可单独设小一点(如 3)
- 返回新的 `AgentRunResult`
**与 frontmatter 的配合**:
- system prompt 已含 `outputFormatInstruction`;最后一轮可强制 user:`Now output your final answer with YAML frontmatter only if you have not yet.`
- 仍依赖 `createAgent` 的 fast-path + 最多 2 次 continue
**安全**:
- `run_command`:白名单或需 `UWF_BUILTIN_ALLOW_SHELL=1`,默认工作区限定在 `process.cwd()` 或 `start` 中将来扩展的 `workspace` 字段
- 路径:禁止 `..` 逃逸出 workspace root
---
### Toolkit 设计
统一注册表:
```typescript
type BuiltinTool = {
name: string;
description: string;
parameters: JSONSchema; // object type
execute: (args: unknown, ctx: ToolContext) => Promise<string>;
};
type ToolContext = {
cwd: string;
storageRoot: string;
};
```
| Tool name | OpenAI function | 行为摘要 |
|-----------|-----------------|----------|
| `read_file` | `read_file` | `{ path }` → UTF-8 文本,大小上限 |
| `write_file` | `write_file` | `{ path, content }` → 写盘,返回确认 |
| `edit_file` | 可选 | search/replace 块,减少 token |
| `run_command` | `run_command` | `{ command, cwd? }` → stdout/stderr 截断 |
| `list_dir` | `list_dir` | `{ path }` → 条目列表 |
| `grep` | `grep` | `{ pattern, path? }` → 匹配行 |
**LLM 请求形状**(扩展 extract 客户端):
```json
{
"model": "...",
"messages": [...],
"tools": [{ "type": "function", "function": { "name", "description", "parameters" } }],
"tool_choice": "auto"
}
```
解析 `choices[0].message.tool_calls`,执行后以 `{ role: "tool", tool_call_id, content }` 回传。
**不提供** streaming 首版;detail CAS 记录每轮 tool 名/参数/结果摘要供 `uwf thread step-details` 调试。
---
### 与现有架构的集成
| 集成点 | 方式 |
|--------|------|
| CLI 协议 | 实现标准 agent CLI:`uwf-builtin <thread-id> <role>`,stdout 一行 step hash,exit 0/1 |
| 工厂 | `export function createBuiltinAgent()` → `createAgent({ name: "builtin", run, continue })` |
| Context / Prompt | 复用 `buildContextWithMeta`、`buildRolePrompt`、`buildOutputFormatInstruction`;prompt 布局对齐 `buildHermesPrompt` |
| 结构化输出 | 优先 YAML frontmatter fast-path;可选后续在 `createAgent` 增加 `extract()` fallback 开关 |
| 配置 | `config.yaml` 增加 `agents.builtin`;`uwf setup` 可选默认 agent |
| 存储 | `resolveStorageRoot()` + `loadWorkflowConfig` + `getEnvPath`;与 Hermes 相同,**不**改 `threads.yaml` 写入方 |
| 测试 | 单元测试:tool handlers、prompt 组装、mock LLM tool loop;集成测试:临时 storage root + fake provider |
| 发布 | 新包 `@uncaged/workflow-agent-builtin`,bin `uwf-builtin`,加入 `scripts/publish-all.mjs` |
**明确不做**:
- 不替代 moderator / 不在 agent 内调用 `uwf thread step`
- 不依赖 Hermes/OpenClaw/Claude Code 二进制
- 首版不实现 streaming、不实现 MCP
**建议实现顺序**:
1. `llm.ts`:tool calling HTTP 客户端 + 单测
2. P0 tools + `runBuiltinLoop`
3. `createBuiltinAgent` + detail CAS
4. `config` / docs / `examples` 可选 `agentOverrides` 演示
5. (可选)`createAgent` 接入 `extract()` fallback
@@ -1,73 +0,0 @@
# Issue #418: ACP session/resume 返回空文本
## 调研日期: 2026-05-23
## 根因
`session/resume` 在 restore 路径下 `_make_agent()` 失败,异常被静默吞掉。
### 完整调用链
```
resume_session(sid)
→ update_cwd(sid)
→ get_session(sid) → _restore(sid)
→ _make_agent()
→ resolve_runtime_provider("custom") 失败(line 548-561)
→ AIAgent() 抛出 "No LLM provider configured"(line 564)
→ except Exception 静默吞掉(line 482-484)→ return None
→ return None
→ state is None → fallback: create_session()(新 sid,无历史)
```
### 关键代码位置(acp_adapter/session.py)
- `_restore()` line 426-498: 从 DB 恢复 session,但 except 太宽泛
- `_make_agent()` line 520-568: provider 解析在 restore 路径下不完整
- Line 548-561: `resolve_runtime_provider("custom")` 失败后,`base_url` 虽然从 DB 取到了但没传给 AIAgent
### 实测行为
1. Phase 1: `session/new` + `prompt` → 正常,有 `agent_message_chunk`
2. Phase 2: `session/resume` + `prompt`
- resume 返回成功,但 `available_commands_update` 里 sessionId 是新的(create_session fallback)
- 用原始 sid 发 prompt → `stopReason: "refusal"`(session 不在内存中)
- 用新 sid 发 prompt → 能跑但无历史(agent 回答"不知道 secret code")
### 验证脚本
```python
# 直接调用 _restore 验证
cd ~/.hermes/hermes-agent
python3 -c "
import sys; sys.path.insert(0, '.')
from acp_adapter.session import SessionManager
sm = SessionManager()
result = sm._restore('SESSION_ID_HERE')
print(result) # None — _make_agent 抛异常被吞掉
"
```
### 两个 bug
1. **`_make_agent` provider fallback 不完整**: restore 时 DB 里有 `base_url``api_mode`,但 `resolve_runtime_provider` 失败后这些值没被正确传递给 AIAgent
2. **`_restore` 的 except 太宽泛**: 静默吞掉所有异常,连 warning 都只在 debug 级别,导致 resume 失败完全无感知
### Hermes 版本
- v0.10.0 (2026.4.16) — 初始测试
- v0.14.0 (2026.5.16) — 更新后重新测试,bug 仍在
- 代码路径: ~/.hermes/hermes-agent/acp_adapter/session.py
### v0.14.0 测试结果 (2026-05-23)
- `_restore` 仍因 `custom` provider 解析失败返回 None
- 日志更清晰了:`WARNING: Failed to recreate agent for ACP session ...`
- resume fallback 创建新 session(新 sid),但 agent 居然能回答之前的问题(可能通过 memory/session search)
- 核心问题不变:sessionId 变了,client 用旧 sid 发 prompt → refusal
### 上游 Issue
- https://github.com/NousResearch/hermes-agent/issues/13489 — 已评论根因分析
- https://github.com/NousResearch/hermes-agent/issues/8083 — resume 静默创建新 session
- https://github.com/NousResearch/hermes-agent/issues/18452 — _make_agent fallback 不完整
File diff suppressed because it is too large Load Diff
@@ -1,387 +0,0 @@
# 设计文档:office-agent 文档生成/编辑 Workflow 体系
**日期:** 2026-05-18
---
## 概述
在 monorepo 中新增三个包,实现通过 `office-agent` CLI 生成或编辑 Word 文档的完整 workflow 体系。
| 包 | npm name | 职责 |
|---|---|---|
| `workflow-template-document` | `@uncaged/workflow-template-document` | 纯结构:角色定义、meta schema、调度表、descriptor |
| `workflow-agent-office` | `@uncaged/workflow-agent-office` | writer 角色执行器:调用 `office-agent` CLI |
| `workflow-agent-docx-diff` | `@uncaged/workflow-agent-docx-diff` | differ 角色执行器:调用 `docx-diff` CLI |
Template 只定义结构,不含执行逻辑。执行器与 template 解耦。
---
## 一、`workflow-template-document`
### Thread 启动输入
```typescript
// src/types.ts
type DocumentStartInput = {
prompt: string; // 用户指令
inputDocx: string | null; // null = 生成模式;本机绝对路径 = 编辑模式
};
```
start.content 为 JSON `{ prompt, inputDocx }` 或纯文本(fallback:generate 模式,整段作为 prompt)。
### 角色与 Meta
`WriterMeta` 使用 discriminated union,在 schema 层区分两种模式:
```typescript
const writerMetaSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("generate"),
outputDocx: z.string(), // 生成产物绝对路径
sourceDocx: z.null(),
}),
z.object({
mode: z.literal("edit"),
outputDocx: z.string(), // 修改后产物:<outputDir>/modified.docx
sourceDocx: z.string(), // 原始副本:<outputDir>/original.docx
}),
]);
type WriterMeta = z.infer<typeof writerMetaSchema>;
// differ:仅编辑模式执行
const differMetaSchema = z.object({
sourceDocx: z.string(),
modifiedDocx: z.string(),
diffDocx: z.string(),
});
type DifferMeta = z.infer<typeof differMetaSchema>;
```
两个角色的 `systemPrompt` 均为 `""`
### 调度表
```
START → writer ──(mode = "edit")──→ differ → END
↘(mode = "generate")→ END
```
### 公开导出
template 导出两个对象供消费方使用:
- `documentWorkflowDefinition: WorkflowDefinition<DocumentMeta>` — 传入 `createWorkflow``def` 参数
- `buildDocumentDescriptor(): WorkflowDescriptor` — bundle 导出用
```typescript
// bundle 侧用法
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, { adapter, overrides });
```
### 包文件结构
```
packages/workflow-template-document/
src/
types.ts # DocumentStartInput
roles/
writer.ts # writerMetaSchema, WriterMeta, writerRole
differ.ts # differMetaSchema, DifferMeta, differRole
index.ts
roles.ts # DocumentMeta, documentRoles
moderator.ts # writerIsEditMode condition + documentTable
definition.ts # documentWorkflowDefinition
descriptor.ts # buildDocumentDescriptor()
index.ts
__tests__/
moderator.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"zod": "^4.0.0"
}
```
---
## 二、`workflow-agent-office`
### office-agent CLI 接口
```bash
# 生成模式:在 CWD 生成 output.docx
office-agent create "<prompt>" -o output.docx
# 编辑模式:在 CWD 对 modified.docx 进行修改(覆写)
office-agent edit modified.docx "<instruction>"
```
- 两个命令均为阻塞调用(CLI 内部消费 SSE,退出即完成)
- 输出文件落到调用方设定的 CWD
- 退出码 0 = 成功,非零 = 失败
### 文件命名约定
| 模式 | 文件 | 路径 |
|---|---|---|
| generate | 输出 | `<outputDir>/output.docx` |
| edit | 原始副本(workflow-owned 快照) | `<outputDir>/original.docx` |
| edit | 修改后产物 | `<outputDir>/modified.docx` |
edit 模式先将 `inputDocx` 复制为 `original.docx`(不可变快照),再复制为 `modified.docx`,对 `modified.docx` 调用 CLI。agent 覆写 `modified.docx``original.docx` 保持不变。differ 对比这两个 workflow-owned 文件,不依赖用户原始路径。
### 执行流程
**生成模式(`inputDocx = null`):**
1. `mkdir -p <outputDir>``<config.outputDir>/<ctx.threadId>`
2. `const command = config.command ?? "office-agent"`
3. `spawnCli(command, ["create", prompt, "-o", "output.docx"], { cwd: outputDir, timeoutMs })`
4. 验证 `outputDir/output.docx` 存在
5. 返回 `JSON.stringify({ mode: "generate", outputDocx, sourceDocx: null })`
**编辑模式(`inputDocx ≠ null`):**
1. `mkdir -p <outputDir>`
2. `copyFile(inputDocx, <outputDir>/original.docx)`
3. `copyFile(inputDocx, <outputDir>/modified.docx)`
4. `const command = config.command ?? "office-agent"`
5. `spawnCli(command, ["edit", "modified.docx", prompt], { cwd: outputDir, timeoutMs })`
6. 验证 `outputDir/modified.docx` 存在
7. 返回 `JSON.stringify({ mode: "edit", outputDocx: modifiedPath, sourceDocx: originalPath })`
### AdapterFn 实现(直接实现,不经过 runtime.extract)
CLI 产出确定性 JSON,直接 `schema.parse(JSON.parse(raw))` 跳过 LLM extraction:
```typescript
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
const raw = await runOfficeAgent(config, ctx.threadId, prompt, inputDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
`_systemPrompt` 为 writer 角色的 systemPrompt(空字符串),实际指令从 `ctx.start.content` 解析。
### 配置
```typescript
type OfficeAgentConfig = {
outputDir: string; // 输出根目录,runner 在此下按 threadId 建子目录
command: string | null; // null → runner 内 resolve 为 "office-agent"
timeout: number | null; // null → 不设超时;单位 ms
};
```
### 错误处理
```typescript
if (!result.ok) {
const e = result.error;
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout")
throw new Error("office-agent: timed out");
// "spawn_failed"
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
if (!existsSync(expectedPath))
throw new Error(`office-agent: output file not found: ${expectedPath}`);
```
### packageDescriptor
```typescript
// src/package-descriptor.ts
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
configSchema: {
type: "object",
required: ["outputDir"],
properties: {
outputDir: { type: "string", description: "Root directory for workflow outputs." },
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to office-agent CLI; null uses PATH." },
timeout: { anyOf: [{ type: "number" }, { type: "null" }], description: "Timeout in ms; null means no limit." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-office/
src/
types.ts # OfficeAgentConfig, OfficeAgentOpt
runner.ts # runOfficeAgent()(spawnCli 封装 + 文件验证)
agent.ts # createOfficeAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
}
```
---
## 三、`workflow-agent-docx-diff`
`differ` 角色专用执行器。从 `ctx.steps` 读取 `WriterMeta`,调用本地 `docx-diff` CLI。
### docx-diff 退出码约定
| 退出码 | 含义 | runner 处理 |
|---|---|---|
| 0 | 无差异 | 正常,验证 diffDocx 存在 |
| 1 | 有差异 | 正常(显式处理为成功),验证 diffDocx 存在 |
| 2+ | 错误 | throw |
runner 收到 `SpawnCliError { kind: "non_zero_exit", exitCode: 1 }` 时视为成功,验证文件后继续;`exitCode >= 2` 才 throw。
### 执行流程
```
1. 从 ctx.steps 找到 writer 步骤,读取 WriterMeta
2. 验证 mode === "edit"(否则 throw)
3. diffDocx = join(dirname(writer.outputDocx), "diff.docx")
4. const command = config.command ?? "docx-diff"
5. spawnCli(command,
[writer.sourceDocx, writer.outputDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null })
exit 0 或 1 → 验证 diffDocx 存在
exit 2+ → throw
6. 返回 JSON.stringify({ sourceDocx, modifiedDocx: writer.outputDocx, diffDocx })
```
### AdapterFn 实现(直接实现,不经过 runtime.extract)
```typescript
export function createDocxDiffAgent(config: DocxDiffAgentConfig = { command: null }): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find(s => s.role === "writer");
if (!writerStep) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit")
throw new Error("differ: writer did not run in edit mode");
const raw = await runDocxDiff(config, writerMeta);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
```
### 配置
```typescript
type DocxDiffAgentConfig = {
command: string | null; // null → runner 内 resolve 为 "docx-diff"
};
```
### packageDescriptor
```typescript
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: { anyOf: [{ type: "string" }, { type: "null" }], description: "Path to docx-diff CLI; null uses PATH." },
},
additionalProperties: false,
},
};
```
### 包文件结构
```
packages/workflow-agent-docx-diff/
src/
types.ts # DocxDiffAgentConfig
runner.ts # runDocxDiff()(exit 1 处理 + 文件验证)
agent.ts # createDocxDiffAgent(): AdapterFn
package-descriptor.ts # packageDescriptor
index.ts
__tests__/
runner.test.ts
agent.test.ts
package.json
tsconfig.json
```
### 依赖
```json
{
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^"
}
```
---
## 四、外部 bundle(外部 workspace 消费)
```typescript
import { createOfficeAgent } from "@uncaged/workflow-agent-office";
import { createDocxDiffAgent } from "@uncaged/workflow-agent-docx-diff";
import {
buildDocumentDescriptor,
documentWorkflowDefinition,
} from "@uncaged/workflow-template-document";
import { createWorkflow } from "@uncaged/workflow-runtime";
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
import { join } from "node:path";
const outputDir = join(getDefaultWorkflowStorageRoot(), "outputs");
export const descriptor = buildDocumentDescriptor();
export const run = createWorkflow(documentWorkflowDefinition, {
adapter: createOfficeAgent({ outputDir, command: null, timeout: null }),
overrides: { differ: createDocxDiffAgent() },
});
```
---
## 不在范围内
- 重试逻辑(失败直接 throw)
- office-agent server 的启停管理(假设 server 已在运行)
- docx-diff HTML/terminal 格式输出(仅 docx)
- 跨机器执行(`inputDocx` 须为本机有效绝对路径)
-539
View File
@@ -1,539 +0,0 @@
# `uwf` — Stateless Workflow CLI
> 将 workflow 引擎降维为无状态单步 CLI。Workflow 是纯数据(CAS 节点),执行是单步原子操作,agent 是可插拔外部命令。
---
## 1. CLI Design
### 1.1 命令总览
```
# thread 组
uwf thread start <workflow> -p <prompt> # 创建 thread,不执行
uwf thread step <thread-id> [--agent] # 单步执行
uwf thread show <thread-id> # thread-id → head 查询
uwf thread list [--all] # 列出活跃 threads(--all 含已归档)
uwf thread kill <thread-id> # 终结 thread,归档
# workflow 组
uwf workflow put <file.yaml> # 注册 workflow(YAML → CAS)
uwf workflow show <workflow-id> # 查看 workflow 定义
uwf workflow list # 列出已注册 workflows
```
两组对称,各 3-4 个子命令。CAS 操作交给 `json-cas` CLI,不在 `uwf` 中重复。
### 1.2 `uwf thread start`
```bash
uwf thread start <workflow> -p "Fix the login bug described in issue #42"
```
- `<workflow>` — workflow 名或 CAS hash
- `-p` — 用户 prompt(必填)
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW", // workflow CAS hash (XXH64, 13-char Crockford Base32)
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T" // ULID
}
```
**做的事:**
1. 解析 workflow(名字查 registry → CAS hash)
2. 生成 thread ULID
3. 写 StartNode 到 CAS
4. 在 threads.yaml 中记录链头 → StartNode hash
5. 输出 JSON
### 1.3 `uwf thread step`
```bash
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T
uwf thread step 01J7K9M2XNPQR5VWBCDF8G3H4T --agent "bunx uwf-cursor"
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA", // 新链头 StepNode 的 CAS hash
"done": false // true = moderator 返回 END,thread 已归档
}
```
`done: true` 时 head 仍然有值(最后一个 StepNode),但 thread 已从 threads.yaml 移除。
对已结束或不存在的 thread 调用 step 会报错(非 active thread)。
详细信息通过 `uwf thread show <thread-id>``json-cas get <head>` 查看。
**做的事:**
1. 读链头 → 当前 StepNode(或 StartNode)
2. 收集 thread 历史(遍历链)
3. 调 moderator:评估 JSONata conditions → 得到下一个 role(或 END)
4. 若 END → 归档 thread,输出最后链头,退出
5. 确定 agent command(`--agent` override > config.yaml per-workflow/role > config.yaml defaultAgent)
6. 调用:`<agent-cmd> <thread-id> <role>`,捕获 stdout 得到新 StepNode hash
7. 更新链头指针
8. 再次调 moderator(基于新 StepNode)判断 done
9. 输出 JSON
### 1.4 `uwf thread show`
```bash
uwf thread show 01J7K9M2XNPQR5VWBCDF8G3H4T
```
**输出(JSON to stdout):**
```jsonc
{
"workflow": "4KNM2PXR3B1QW",
"thread": "01J7K9M2XNPQR5VWBCDF8G3H4T",
"head": "8FWKR3TN5V1QA",
"done": false
}
```
纯 thread-id → head 查询。详细内容用 `json-cas get <head>``json-cas walk <head>` 查看。
### 1.5 Agent CLI 协议
每个 agent 是一个命令,接受 thread-id 和 role 两个参数:
```bash
uwf-hermes <thread-id> <role>
```
**约定:**
- `uwf step` 负责 moderator 决策,将 role 传给 agent CLI
- agent-kit 根据 thread + role 从 CAS 读 goal / capabilities / procedure / output / meta
- agent-kit 组装完整 prompt(role goal/capabilities/procedure/output + thread context + user prompt from StartNode)
- agent 执行实际逻辑,agent-kit 负责 extract
- agent 将 StepNode 写入 CAS(含 output、detail、agent、prev),但**不挪链头指针**
- stdout 输出新 StepNode 的 CAS hash(纯文本,一行)
- 所有配置从环境变量读(LLM model、API key、extractor config)
- exit 0 = 成功,非 0 = 失败
**stdout 输出:**
```
8FWKR3TN5V1QA
```
`uwf step` 拿到这个 hash 后更新链头指针、判断 done。
---
## 2. CAS 结构定义
### 2.1 类型层级
沿用 json-cas 的三层:bootstrap meta-schema → JSON Schema nodes → data nodes。
下面所有 CAS 节点都遵循 `{ type: cas_ref, payload: T, timestamp: number }` 的标准格式。
`cas_ref` 类型的字符串字段在 json-cas 中已内置支持,不需要额外的 `$ref` 包装。
### 2.2 数据节点
#### `Workflow`
Roles 和 moderator 内联在 Workflow 中,只有 meta 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent..."
capabilities: [planning, issue-analysis]
procedure: "Analyze the issue and create a plan."
output: "Output the plan summary."
meta: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
developer:
description: "Implements code changes"
goal: "You are a developer agent..."
capabilities: [file-edit, shell]
procedure: "Implement the plan."
output: "List all files changed."
meta: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer..."
capabilities: [code-review]
procedure: "Review the implementation."
output: "Approve or reject with comments."
meta: "1VPBG9SM5E7WK" # cas_ref → JSON Schema 节点
conditions:
needsClarification:
description: "Planner requests clarification from user"
expression: "$exists(steps[-1].output.needsClarification)"
notApproved:
description: "Reviewer rejected the implementation"
expression: "steps[-1].output.approved = false"
graph:
$START:
- role: "planner"
condition: null # 无条件(fallback)
planner:
- role: "developer"
condition: "needsClarification"
- role: "$END"
condition: null
developer:
- role: "reviewer"
condition: null
reviewer:
- role: "developer"
condition: "notApproved"
- role: "$END"
condition: null
```
- `roles` — 内联定义,每个 role 的 `meta` 是独立的 cas_ref(指向 json-cas 内置 JSON Schema 节点)
- `conditions``Record<Name, JSONata>`,命名条件,方便画图描述
- `graph``Record<Role | "$START", Transition[]>`,每个 Transition = `{ role, condition }`
- `condition` 引用 conditions 中的 key,`null` = fallback
- 按数组顺序求值,第一个匹配的 transition 胜出
- 不含 agent binding — agent 配置在 `~/.uncaged/workflow/config.yaml` 中管理
JSONata 表达式的求值上下文:
```jsonc
{
"start": { // StartNode 信息
"workflow": "4KNM2PXR3B1QW",
"prompt": "Fix the login bug..."
},
"steps": [ // 所有已完成 steps,从旧到新
{ "role": "planner", "output": { "phases": [...] }, "detail": "7BQST3VW9F2MA", "agent": "uwf-hermes" },
{ "role": "developer", "output": { "filesChanged": ["src/auth.ts"], "summary": "Fixed redirect" }, "detail": "9KRVW3TN5F1QA", "agent": "uwf-cursor" },
{ "role": "reviewer", "output": { "approved": false }, "detail": "2MXBG6PN4A8JR", "agent": "uwf-hermes" }
]
}
```
注:`output` 在上下文中会被自动展开为实际的 CAS 节点内容(而非 hash),方便 JSONata 表达式直接访问字段。
#### `StartNode`(Thread 起点)
```yaml
type: <start-node-schema-hash>
payload:
workflow: "4KNM2PXR3B1QW" # cas_ref → Workflow
prompt: "Fix the login bug..."
```
- 没有 thread-id — thread-id 是索引层面的事,不进 CAS 内容
- 没有 agent binding — 运行时从 config.yaml 解析
#### `StepNode`(Thread 每一步)
```yaml
type: <step-node-schema-hash>
payload:
start: "4TNVW8KR2B3MA" # cas_ref → StartNode(每个 step 都引用)
prev: "2MXBG6PN4A8JR" # cas_ref → 前一个 StepNode,第一步为 null
role: "developer"
output: "9KRVW3TN5F1QA" # cas_ref → 结构化输出节点(符合 role 的 meta schema)
detail: "7BQST3VW9F2MA" # cas_ref → 执行详情(content node / 子 workflow terminal StepNode / ...)
agent: "uwf-cursor" # 实际使用的 agent 命令(纯字符串)
```
- `start` — 每个 StepNode 都直接引用 StartNode,方便随机访问
- `prev` — 前一个 StepNode 的 cas_ref,第一步为 `null`(不指向 StartNode)
- `output` — cas_ref,指向符合 role meta schema 的 CAS 节点,可用 json-cas 校验
- `detail` — cas_ref,指向执行详情。可以是原始 agent 输出(content node),也可以是子 workflow thread 的 terminal StepNode(workflowAsAgent 场景)
- `agent` — 纯字符串,不是 CAS 节点
### 2.3 链式结构
```
threads.yaml: { "01J7K9M2XNPQR5VWBCDF8G3H4T": "8FWKR3TN5V1QA" }
StepNode (step 3)
├── start ──→ StartNode
│ ├── workflow → CAS(Workflow)
│ └── prompt: "Fix..."
├── prev ──→ StepNode (step 2)
│ ├── start ──→ (same StartNode)
│ ├── prev ──→ StepNode (step 1)
│ │ ├── start ──→ (same StartNode)
│ │ ├── prev: null
│ │ ├── role: "planner"
│ │ └── ...
│ ├── role: "developer"
│ └── ...
├── role: "reviewer"
├── output → CAS({ approved: true })
├── detail → CAS(raw output | sub-workflow terminal node)
└── agent: "uwf-hermes"
```
### 2.4 可变状态
系统两个顶层 YAML 文件和一个 env 文件:
```yaml
# ~/.uncaged/workflow/config.yaml — 全局配置
providers:
openai:
baseUrl: "https://api.openai.com/v1"
apiKeyEnv: "OPENAI_API_KEY"
anthropic:
baseUrl: "https://api.anthropic.com/v1"
apiKeyEnv: "ANTHROPIC_API_KEY"
openrouter:
baseUrl: "https://openrouter.ai/api/v1"
apiKeyEnv: "OPENROUTER_API_KEY"
models:
sonnet:
provider: "openrouter"
name: "anthropic/claude-sonnet-4"
gpt4o-mini:
provider: "openai"
name: "gpt-4o-mini"
agents:
hermes:
command: "uwf-hermes"
args: []
cursor:
command: "uwf-cursor"
args: []
defaultAgent: "hermes"
agentOverrides:
solve-issue:
developer: "cursor"
defaultModel: "sonnet"
modelOverrides:
extract: "gpt4o-mini"
```
```yaml
# ~/.uncaged/workflow/threads.yaml — active thread 链头指针
01J7K9M2XNPQR5VWBCDF8G3H4T: "8FWKR3TN5V1QA"
01J8AB3QRMSTV6WKXZ2C4DF7GN: "3CNWT9KR6D2HV"
```
Thread 结束时从 threads.yaml 移除。可选:追加到 `history.jsonl` 做归档。
```bash
# ~/.uncaged/workflow/.env — 敏感信息(API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
OPENROUTER_API_KEY=sk-or-...
```
- `config.yaml` — 非敏感配置(agent 命令、model 名、provider 名)
- `.env` — 敏感信息(API keys),agent-kit 启动时自动加载
- `threads.yaml` — 运行时状态
---
## 3. 包结构
全新包,不复用现有 packages,避免命名冲突。CAS 直接依赖 `@uncaged/json-cas`
```
packages/
├── cli-workflow/ # @uncaged/cli-workflow — uwf CLI(thread/workflow 命令)
├── workflow-moderator/ # @uncaged/workflow-moderator — JSONata moderator 引擎
├── workflow-agent-kit/ # @uncaged/workflow-agent-kit — Agent CLI 框架(含 extractor)
├── workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — uwf-hermes CLI
├── workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — uwf-cursor CLI
└── workflow-protocol/ # @uncaged/workflow-protocol — 共享类型定义
```
**外部依赖:**
- `@uncaged/json-cas` — CAS 存储、hash、schema 校验
- `@uncaged/json-cas-fs` — 文件系统 CAS 后端
**现有包全部保留不动**,新旧并存,逐步迁移。
---
## 4. 关键数据类型
JSONata 求值上下文本质上是 thread 链表的线性化表达。StepNode payload 和上下文中的 step 共享大量字段,提取为公共类型。
### 4.1 公共类型
```typescript
/** CAS hash — XXH64, 13-char Crockford Base32 */
type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
type StepRecord = {
role: string;
output: CasRef; // cas_ref → 结构化输出节点(符合 role meta schema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
goal: string;
capabilities: string[];
procedure: string;
output: string;
meta: CasRef; // cas_ref → json-cas 内置 JSON Schema 节点
};
type Transition = {
role: string; // 目标 role 名 或 "$END"
condition: string | null; // 引用 conditions 中的 key,null = fallback
};
type ConditionDefinition = {
description: string;
expression: string; // JSONata expression
};
type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>; // Record<Role | "$START", Transition[]>
};
```
### 4.3 Thread 节点
```typescript
type StartNodePayload = {
workflow: CasRef; // cas_ref → Workflow
prompt: string;
};
type StepNodePayload = StepRecord & {
start: CasRef; // cas_ref → StartNode(每个 step 都引用)
prev: CasRef | null; // cas_ref → 前一个 StepNode,第一步为 null
};
```
### 4.4 JSONata 求值上下文
Thread 链表的线性化。`steps[n]` 的字段和 `StepRecord` 一致,但 `output` 被展开为实际内容。
```typescript
/** JSONata 上下文中的 step — output 被展开 */
type StepContext = Omit<StepRecord, "output"> & {
output: unknown; // 展开后的 CAS 节点内容,非 hash
};
type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[]; // 从旧到新
};
```
### 4.5 CLI 输出
```typescript
/** uwf thread start */
type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread list */
type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
```
### 4.6 配置
```typescript
/** Alias types for config references */
type AgentAlias = string;
type ModelAlias = string;
type ProviderAlias = string;
type WorkflowName = string;
type RoleName = string;
type Scenario = string; // e.g. "extract"
type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string; // env var name to read API key from
};
type ModelConfig = {
provider: ProviderAlias;
name: string; // e.g. "anthropic/claude-sonnet-4", "gpt-4o-mini"
};
type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
type WorkflowConfig = {
providers: Record<ProviderAlias, ProviderConfig>;
models: Record<ModelAlias, ModelConfig>;
agents: Record<AgentAlias, AgentConfig>;
defaultAgent: AgentAlias;
agentOverrides: Record<WorkflowName, Record<RoleName, AgentAlias>> | null;
defaultModel: ModelAlias;
modelOverrides: Record<Scenario, ModelAlias> | null;
};
/** ~/.uncaged/workflow/threads.yaml */
type ThreadsIndex = Record<ThreadId, CasRef>;
// ^ thread-id ^ head StepNode/StartNode hash
```
### 4.7 类型关系图
```
WorkflowConfig (config.yaml)
ThreadsIndex (threads.yaml) ← 唯二可变状态
│ thread-id → head hash
StepNodePayload ──extends──→ StepRecord ←──maps to──→ StepContext
│ │ │
├── start → StartNodePayload│ │ (output 展开)
├── prev → StepNodePayload │ │
│ ├── role ├── role
│ ├── output (CasRef) ├── output (展开)
│ ├── detail (CasRef) ├── detail (CasRef)
│ └── agent (string) └── agent (string)
└── start.workflow → WorkflowPayload
├── roles: Record<name, RoleDefinition>
├── conditions: Record<name, JSONata>
└── graph: Record<role, Transition[]>
```
-43
View File
@@ -1,43 +0,0 @@
name: "analyze-topic"
description: "Single-role topic analysis using four-phase role description"
roles:
analyst:
description: "Analyzes a given topic and produces a structured summary"
goal: |
You are a research analyst with expertise in breaking down complex topics
into clear, structured summaries. You think critically and cite key points.
capabilities:
- research
- critical-thinking
- structured-writing
procedure: |
Analyze the topic by:
1. Identifying the main thesis or question
2. Listing 3-5 key points with brief explanations
3. Noting any counterarguments or caveats
Keep your analysis concise (under 500 words).
output: |
Provide your analysis as markdown under the frontmatter.
The frontmatter must include your structured findings.
frontmatter:
type: object
properties:
thesis:
type: string
keyPoints:
type: array
items:
type: string
caveats:
type: string
required: [thesis, keyPoints]
conditions: {}
graph:
$START:
- role: "analyst"
condition: null
prompt: "Analyze the topic in the task and produce a structured summary with key points."
analyst:
- role: "$END"
condition: null
prompt: "Analysis complete. Finish the workflow."
-77
View File
@@ -1,77 +0,0 @@
name: "debate"
description: "Structured debate between two sides. Tests cross-process session resume."
roles:
against:
description: "Argues against the proposition"
goal: |
You are a skilled debater arguing AGAINST the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
1. If this is the opening, present your strongest argument against the proposition.
2. If responding to the other side, directly counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
frontmatter:
type: object
properties:
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
for:
description: "Argues for the proposition"
goal: |
You are a skilled debater arguing FOR the proposition.
Be logical, cite evidence, and directly address your opponent's points.
Keep each argument concise (under 200 words).
capabilities:
- argumentation
- critical-thinking
procedure: |
1. Read the opposing side's latest argument carefully.
2. Counter their points with evidence and logic.
3. If you find yourself genuinely convinced by the other side, you may concede.
output: |
Provide your argument in the frontmatter.
Set conceded to true ONLY if you are genuinely convinced and wish to stop debating.
frontmatter:
type: object
properties:
argument:
type: string
conceded:
type: boolean
required: [argument, conceded]
conditions:
againstConceded:
description: "The against side conceded"
expression: "$last('against').conceded = true"
forConceded:
description: "The for side conceded"
expression: "$last('for').conceded = true"
graph:
$START:
- role: "against"
condition: null
prompt: "Present your opening argument against the proposition."
against:
- role: "$END"
condition: "againstConceded"
prompt: "The against side conceded. Debate over."
- role: "for"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
for:
- role: "$END"
condition: "forConceded"
prompt: "The for side conceded. Debate over."
- role: "against"
condition: null
prompt: "Counter the opposing argument. Address their points directly."
-102
View File
@@ -1,102 +0,0 @@
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
goal: "You are a planning agent. You analyze issues and create implementation plans grounded in the actual codebase."
capabilities:
- issue-analysis
- planning
- file-read
- shell
procedure: |
1. Locate the code repository:
- Check if the current working directory is the repo (look for package.json, .git, etc.)
- If the task mentions a repo URL, clone it first.
- If this is a new project, create the repo and note the path.
2. Explore the codebase — read the relevant source files mentioned in the issue. Understand the current architecture, types, and conventions (check CLAUDE.md, CONTRIBUTING.md, .cursor/rules/).
3. Identify which files need changes and what the changes should be, with specific code references.
4. Output the plan with:
- `repoPath`: absolute path to the repository root
- `plan`: detailed implementation plan with file paths and code references
- `steps`: concrete action items for the developer
output: |
Provide repoPath, plan summary, and steps in the frontmatter.
The plan MUST reference actual file paths and code structures you found by reading the source.
Do NOT guess — if you haven't read a file, read it before referencing it.
frontmatter:
type: object
properties:
repoPath:
type: string
plan:
type: string
required: [repoPath, plan]
developer:
description: "Implements code changes"
goal: "You are a developer agent. You implement code changes according to plans."
capabilities:
- file-edit
- shell
- testing
procedure: |
1. Read the planner's output to get the repoPath and implementation plan.
2. cd to the repoPath before making any changes.
3. Create a feature branch from the default branch.
4. Implement the plan — write code, tests, and ensure existing tests pass.
5. Run the project's lint/check command (e.g. `bun run check`, `npm run lint`) and fix ALL errors before proceeding. Build and lint must pass cleanly.
6. Commit your changes with a descriptive message referencing the issue.
output: "List all files changed and provide a summary of the implementation."
frontmatter:
type: object
properties:
filesChanged:
type: array
items:
type: string
summary:
type: string
required: [filesChanged, summary]
reviewer:
description: "Reviews code changes"
goal: "You are a code reviewer. You review implementations for correctness and quality."
capabilities:
- code-review
- static-analysis
procedure: |
1. Run hard checks first — build (`bun run build` or equivalent) and lint (`bunx biome check .` or equivalent) MUST pass with zero errors. If they fail, reject immediately.
2. Then review code quality: correctness, edge cases, naming, project conventions (CLAUDE.md), and test coverage.
3. Only reject for hard check failures or genuine correctness/security issues. Style suggestions alone should not block approval.
output: "Approve or reject with detailed comments explaining your decision."
frontmatter:
type: object
properties:
approved:
type: boolean
comments:
type: string
required: [approved, comments]
conditions:
notApproved:
description: "Reviewer rejected the implementation"
expression: "$last('reviewer').approved = false"
graph:
$START:
- role: "planner"
condition: null
prompt: "Analyze the issue described in the task and produce a detailed implementation plan."
planner:
- role: "developer"
condition: null
prompt: "Implement the plan from the planner. Write code, tests, and ensure existing tests pass."
developer:
- role: "reviewer"
condition: null
prompt: "Review the developer's implementation against the plan for correctness and quality."
reviewer:
- role: "developer"
condition: "notApproved"
prompt: "The reviewer rejected your implementation. Read their feedback and fix the issues."
- role: "$END"
condition: null
prompt: "The review passed. Complete the workflow."
-76
View File
@@ -1,76 +0,0 @@
# @uncaged/cli-workflow
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
## Install
```bash
bun add @uncaged/cli-workflow
```
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
## Usage
```bash
uncaged-workflow workflow list
uncaged-workflow run <name> --prompt "Your task"
uncaged-workflow thread show <id>
uncaged-workflow skill
```
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
```
uncaged-workflow — workflow engine CLI
Workflow registry:
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 details of a registered workflow
workflow rm <name> Remove a workflow from the registry
workflow history <name> Show version history of a workflow
workflow rollback <name> [hash] Rollback a workflow to a previous version
Thread execution:
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
thread list [name] List threads, optionally filtered by workflow name
thread show <id> Show thread details and state
thread rm <id> Remove a thread
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
thread ps List running threads
thread kill <thread-id> Kill a running thread
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
thread pause <thread-id> Pause a running thread
thread resume <thread-id> Resume a paused thread
Content-addressable storage:
cas get <hash> Retrieve content by hash from CAS
cas put <content> Store content in CAS, prints hash
cas list List all hashes in CAS
cas rm <hash> Remove a CAS entry by hash
cas gc Garbage-collect unreferenced CAS entries
Development:
init workspace <name> Initialize a new workflow workspace
init template <name> Initialize a new workflow template
Shortcuts:
run <name> [...] → thread run
live <id> [...] → thread live
Reference:
skill [topic] Agent-consumable docs (cli, develop, author)
Use <command> --help for subcommand details.
Environment variables:
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
```
## API overview
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts``runCli` in `src/cli-dispatch.js`.
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-gateway": "workspace:^",
"@uncaged/workflow-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-cas": "workspace:^",
"@uncaged/workflow-execute": "workspace:^",
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
}
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bun
import { runCli } from "./cli-dispatch.js";
import { resolveWorkflowStorageRoot } from "./storage-env.js";
const argv = process.argv.slice(2);
const storageRoot = resolveWorkflowStorageRoot();
const code = await runCli(storageRoot, argv);
process.exit(code);
@@ -1,22 +0,0 @@
import { describe, expect, test } from "bun:test";
import { createDocxDiffAgent } from "../src/agent.js";
import { packageDescriptor } from "../src/package-descriptor.js";
describe("createDocxDiffAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createDocxDiffAgent({ command: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-docx-diff");
});
});
@@ -1,118 +0,0 @@
import { describe, expect, mock, test } from "bun:test";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { err, ok } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { runDocxDiff } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `diff-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("runDocxDiff", () => {
test("exit 0: success, returns DifferMeta JSON", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "original.docx");
const modifiedDocx = join(dir, "modified.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// simulate docx-diff creating the diff file
writeFileSync(diffDocx, "");
const raw = await runDocxDiff(
{ command: "docx-diff" },
sourceDocx,
modifiedDocx,
diffDocx,
spawnFn,
);
const meta = JSON.parse(raw);
expect(meta.sourceDocx).toBe(sourceDocx);
expect(meta.modifiedDocx).toBe(modifiedDocx);
expect(meta.diffDocx).toBe(diffDocx);
expect(spawnFn.mock.calls[0][1]).toEqual([
sourceDocx,
modifiedDocx,
"--output",
"docx",
"--out-file",
diffDocx,
]);
});
test("exit 1 (changes found): treated as success", async () => {
const dir = tempDir();
const sourceDocx = join(dir, "s.docx");
const modifiedDocx = join(dir, "m.docx");
const diffDocx = join(dir, "diff.docx");
writeFileSync(sourceDocx, "");
writeFileSync(modifiedDocx, "");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "" }) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: "docx-diff" }, sourceDocx, modifiedDocx, diffDocx, spawnFn),
).resolves.toBeDefined();
});
test("exit 2: throws error", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(
err({
kind: "non_zero_exit",
exitCode: 2,
stdout: "",
stderr: "fatal error",
}) as MockSpawnResult,
);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("docx-diff failed");
});
test("timeout: throws error", async () => {
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", "diff.docx", spawnFn),
).rejects.toThrow("timed out");
});
test("throws when diff file not created", async () => {
const dir = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// do NOT create diffDocx
await expect(
runDocxDiff({ command: null }, "s.docx", "m.docx", join(dir, "missing.docx"), spawnFn),
).rejects.toThrow("diff file not found");
});
test("uses PATH docx-diff when command is null", async () => {
const dir = tempDir();
const diffDocx = join(dir, "diff.docx");
writeFileSync(diffDocx, "");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
await runDocxDiff({ command: null }, "s.docx", "m.docx", diffDocx, spawnFn);
expect(spawnFn.mock.calls[0][0]).toBe("docx-diff");
});
});
@@ -1,33 +0,0 @@
{
"name": "@uncaged/workflow-agent-docx-diff",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^",
"@uncaged/workflow-template-document": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-util": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,28 +0,0 @@
import { dirname, join } from "node:path";
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import type { WriterMeta } from "@uncaged/workflow-template-document";
import type * as z from "zod/v4";
import { runDocxDiff } from "./runner.js";
import type { DocxDiffAgentConfig } from "./types.js";
export function createDocxDiffAgent(config: DocxDiffAgentConfig): AdapterFn {
return <T>(_prompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) throw new Error("differ: no writer step found");
const writerMeta = writerStep.meta as WriterMeta;
if (writerMeta.mode !== "edit") throw new Error("differ: writer did not run in edit mode");
const diffDocx = join(dirname(writerMeta.outputDocx), "diff.docx");
const raw = await runDocxDiff(config, writerMeta.sourceDocx, writerMeta.outputDocx, diffDocx);
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -1,3 +0,0 @@
export { createDocxDiffAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { DocxDiffAgentConfig } from "./types.js";
@@ -1,17 +0,0 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-docx-diff",
version: "0.1.0",
capabilities: ["docx-diff-cli", "docx-diff-report"],
configSchema: {
type: "object",
properties: {
command: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to docx-diff CLI binary; null uses PATH.",
},
},
additionalProperties: false,
},
};
@@ -1,46 +0,0 @@
import { stat } from "node:fs/promises";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { DocxDiffAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`docx-diff failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout") throw new Error("docx-diff: timed out");
throw new Error(`docx-diff: spawn failed: ${e.message}`);
}
export async function runDocxDiff(
config: DocxDiffAgentConfig,
sourceDocx: string,
modifiedDocx: string,
diffDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<string> {
const command = config.command ?? "docx-diff";
const result = await spawnCliFn(
command,
[sourceDocx, modifiedDocx, "--output", "docx", "--out-file", diffDocx],
{ cwd: null, timeoutMs: null },
);
if (!result.ok) {
const e = result.error;
// exit 1 = changes found (normal for docx-diff)
if (e.kind === "non_zero_exit" && e.exitCode === 1) {
// fall through to file check
} else {
throwSpawnError(e);
}
}
try {
await stat(diffDocx);
} catch {
throw new Error(`docx-diff: diff file not found: ${diffDocx}`);
}
return JSON.stringify({ sourceDocx, modifiedDocx, diffDocx });
}
@@ -1,3 +0,0 @@
export type DocxDiffAgentConfig = {
command: string | null;
};
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util-agent" },
{ "path": "../workflow-template-document" }
]
}
@@ -1,34 +0,0 @@
# @uncaged/workflow-agent-hermes
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
## Install
```bash
bun add @uncaged/workflow-agent-hermes @uncaged/workflow-runtime @uncaged/workflow-util-agent
```
In this monorepo: use `workspace:*` for `@uncaged/workflow-agent-hermes`, `@uncaged/workflow-runtime`, and `@uncaged/workflow-util-agent`.
## Usage
```typescript
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
const agent = createHermesAgent({
model: "your-model", // or null to omit --model
timeout: 600_000, // ms, or null for no timeout
});
```
## API overview
| Export | Description |
|--------|-------------|
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
| `HermesAgentConfig` | `model`, `timeout` |
| `validateHermesAgentConfig` | Config validation result |
Requires `hermes` on `PATH` at runtime.
@@ -1,28 +0,0 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.5.0-alpha.4",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
}
}
@@ -1,72 +0,0 @@
import type { AdapterFn, AgentFn } from "@uncaged/workflow-runtime";
import {
buildThreadInput,
createAgentAdapter,
type SpawnCliError,
spawnCli,
} from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
import { validateHermesAgentConfig } from "./validate-config.js";
const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
function throwHermesSpawnError(error: SpawnCliError): never {
if (error.kind === "non_zero_exit") {
throw new Error(
`hermes: exitCode=${error.exitCode} stdout=${error.stdout} stderr=${error.stderr}`,
);
}
if (error.kind === "timeout") {
throw new Error("hermes: timeout");
}
if (error.kind === "spawn_failed") {
throw new Error(`hermes: ${error.message}`);
}
throw new Error("hermes: unknown spawn error");
}
function createHermesAgentFn(config: HermesAgentConfig): AgentFn<HermesAgentOpt> {
const timeoutMs = config.timeout;
return async (ctx, { prompt }) => {
const threadInput = await buildThreadInput(ctx);
const fullPrompt = `${prompt}\n\n${threadInput}`;
const args = [
"chat",
"-q",
fullPrompt,
"--yolo",
"--max-turns",
String(HERMES_DEFAULT_MAX_TURNS),
"--quiet",
];
if (config.model !== null) {
args.push("--model", config.model);
}
const run = await spawnCli(config.command, args, {
cwd: null,
timeoutMs,
});
if (!run.ok) {
throwHermesSpawnError(run.error);
}
return run.value;
};
}
/** Runs `hermes chat` non-interactively with the Nerve-style argv contract (`-q`, `--yolo`, `--quiet`). */
export function createHermesAgent(config: HermesAgentConfig): AdapterFn {
return createAgentAdapter(createHermesAgentFn(config), async (_ctx, prompt, _runtime) => {
const validated = validateHermesAgentConfig(config);
if (!validated.ok) {
throw new Error(validated.error);
}
return { prompt };
});
}
@@ -1,6 +0,0 @@
export type HermesAgentConfig = {
/** Absolute path to the hermes CLI binary. */
command: string;
model: string | null;
timeout: number | null;
};
@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
}
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test";
import { createOfficeAgent } from "../src/agent.js";
import { packageDescriptor } from "../src/package-descriptor.js";
describe("createOfficeAgent", () => {
test("returns an AdapterFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
expect(typeof agent).toBe("function");
});
test("AdapterFn returns a RoleFn (function)", () => {
const agent = createOfficeAgent({ outputDir: "/tmp", command: null, timeout: null });
const roleFn = agent("", expect.anything() as never);
expect(typeof roleFn).toBe("function");
});
});
describe("packageDescriptor", () => {
test("has correct name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-office");
});
test("has outputDir in configSchema required", () => {
const schema = packageDescriptor.configSchema as { required: string[] };
expect(schema.required).toContain("outputDir");
});
});
@@ -1,135 +0,0 @@
import { describe, expect, mock, test } from "bun:test";
import { mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { err, ok } from "@uncaged/workflow-util";
import type { SpawnCliConfig } from "@uncaged/workflow-util-agent";
import { editDocument, generateDocument } from "../src/runner.js";
type MockSpawnResult = Awaited<ReturnType<typeof import("@uncaged/workflow-util-agent").spawnCli>>;
function makeSpawn(result: MockSpawnResult) {
return mock(async (_cmd: string, _args: string[], _opts: SpawnCliConfig) => result);
}
function tempDir(): string {
const dir = join(tmpdir(), `office-test-${Date.now()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe("generateDocument", () => {
test("calls office-agent create with correct args and returns outputDocx path", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("agent reply") as MockSpawnResult);
// Simulate CLI creating the file
const outFile = join(base, "thread1", "output.docx");
mkdirSync(join(base, "thread1"), { recursive: true });
writeFileSync(outFile, "");
const result = await generateDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"thread1",
"Write a report",
spawnFn,
);
expect(result.outputDocx).toBe(outFile);
expect(result.sourceDocx).toBeNull();
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
expect(spawnFn.mock.calls[0][1]).toEqual(["create", "Write a report", "-o", "output.docx"]);
expect(spawnFn.mock.calls[0][2].cwd).toBe(join(base, "thread1"));
});
test("uses PATH office-agent when command is null", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
mkdirSync(join(base, "t2"), { recursive: true });
writeFileSync(join(base, "t2", "output.docx"), "");
await generateDocument(
{ outputDir: base, command: null, timeout: null },
"t2",
"Generate",
spawnFn,
);
expect(spawnFn.mock.calls[0][0]).toBe("office-agent");
});
test("throws on non_zero_exit", async () => {
const base = tempDir();
const spawnFn = makeSpawn(
err({ kind: "non_zero_exit", exitCode: 1, stdout: "", stderr: "error" }) as MockSpawnResult,
);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t3", "fail", spawnFn),
).rejects.toThrow("office-agent failed (exit 1)");
});
test("throws on timeout", async () => {
const base = tempDir();
const spawnFn = makeSpawn(err({ kind: "timeout" }) as MockSpawnResult);
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t4", "slow", spawnFn),
).rejects.toThrow("office-agent: timed out");
});
test("throws when output file not created", async () => {
const base = tempDir();
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Do NOT create output.docx
await expect(
generateDocument({ outputDir: base, command: null, timeout: null }, "t5", "no file", spawnFn),
).rejects.toThrow("output file not found");
});
});
describe("editDocument", () => {
test("copies input to original.docx and modified.docx, calls edit, returns paths", async () => {
const base = tempDir();
// Create a fake inputDocx
const inputFile = join(base, "source.docx");
writeFileSync(inputFile, "original content");
const spawnFn = makeSpawn(ok("") as MockSpawnResult);
// Simulate CLI overwriting modified.docx
const outDir = join(base, "te1");
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "modified.docx"), "modified content");
const result = await editDocument(
{ outputDir: base, command: "office-agent", timeout: null },
"te1",
"Edit the doc",
inputFile,
spawnFn,
);
expect(result.outputDocx).toBe(join(outDir, "modified.docx"));
expect(result.sourceDocx).toBe(join(outDir, "original.docx"));
expect(spawnFn.mock.calls[0][1]).toEqual(["edit", "modified.docx", "Edit the doc"]);
});
test("throws on spawn_failed", async () => {
const base = tempDir();
const inputFile = join(base, "src.docx");
writeFileSync(inputFile, "");
const spawnFn = makeSpawn(
err({ kind: "spawn_failed", message: "not found" }) as MockSpawnResult,
);
await expect(
editDocument(
{ outputDir: base, command: null, timeout: null },
"te2",
"edit",
inputFile,
spawnFn,
),
).rejects.toThrow("spawn failed");
});
});
@@ -1,29 +0,0 @@
{
"name": "@uncaged/workflow-agent-office",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"@uncaged/workflow-util-agent": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,56 +0,0 @@
import type {
AdapterFn,
RoleResult,
ThreadContext,
WorkflowRuntime,
} from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
import type * as z from "zod/v4";
import { editDocument, generateDocument } from "./runner.js";
import type { OfficeAgentConfig } from "./types.js";
const log = createLogger({ sink: { kind: "stderr" } });
type ParsedInput = { prompt: string; inputDocx: string | null };
function parseStartInput(content: string): ParsedInput {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
if (typeof parsed.prompt === "string") {
return {
prompt: parsed.prompt,
inputDocx: typeof parsed.inputDocx === "string" ? parsed.inputDocx : null,
};
}
} catch {
// not JSON — treat whole content as prompt, generate mode
}
return { prompt: content, inputDocx: null };
}
export function createOfficeAgent(config: OfficeAgentConfig): AdapterFn {
return <T>(_systemPrompt: string, schema: z.ZodType<T>) =>
async (ctx: ThreadContext, _runtime: WorkflowRuntime): Promise<RoleResult<T>> => {
const { prompt, inputDocx } = parseStartInput(ctx.start.content);
log(
"8FQKP3NV",
`office-agent: mode=${inputDocx === null ? "generate" : "edit"} thread=${ctx.threadId}`,
);
let raw: string;
if (inputDocx === null) {
const result = await generateDocument(config, ctx.threadId, prompt);
raw = JSON.stringify({ mode: "generate", outputDocx: result.outputDocx, sourceDocx: null });
} else {
const result = await editDocument(config, ctx.threadId, prompt, inputDocx);
raw = JSON.stringify({
mode: "edit",
outputDocx: result.outputDocx,
sourceDocx: result.sourceDocx,
});
}
const meta = schema.parse(JSON.parse(raw)) as T;
return { meta, childThread: null };
};
}
@@ -1,3 +0,0 @@
export { createOfficeAgent } from "./agent.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { OfficeAgentConfig } from "./types.js";
@@ -1,64 +0,0 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import type { SpawnCliError } from "@uncaged/workflow-util-agent";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { OfficeAgentConfig } from "./types.js";
type SpawnCliFn = typeof spawnCli;
function throwSpawnError(e: SpawnCliError): never {
if (e.kind === "non_zero_exit")
throw new Error(`office-agent failed (exit ${e.exitCode}): ${e.stderr}`);
if (e.kind === "timeout") throw new Error("office-agent: timed out");
throw new Error(`office-agent: spawn failed: ${e.message}`);
}
async function assertFileExists(path: string): Promise<void> {
try {
await stat(path);
} catch {
throw new Error(`office-agent: output file not found: ${path}`);
}
}
export async function generateDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: null }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["create", prompt, "-o", "output.docx"], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
const outputDocx = join(outputDir, "output.docx");
await assertFileExists(outputDocx);
return { outputDocx, sourceDocx: null };
}
export async function editDocument(
config: OfficeAgentConfig,
threadId: string,
prompt: string,
inputDocx: string,
spawnCliFn: SpawnCliFn = spawnCli,
): Promise<{ outputDocx: string; sourceDocx: string }> {
const outputDir = join(config.outputDir, threadId);
await mkdir(outputDir, { recursive: true });
const originalDocx = join(outputDir, "original.docx");
const modifiedDocx = join(outputDir, "modified.docx");
await copyFile(inputDocx, originalDocx);
await copyFile(inputDocx, modifiedDocx);
const command = config.command ?? "office-agent";
const result = await spawnCliFn(command, ["edit", "modified.docx", prompt], {
cwd: outputDir,
timeoutMs: config.timeout,
});
if (!result.ok) throwSpawnError(result.error);
await assertFileExists(modifiedDocx);
return { outputDocx: modifiedDocx, sourceDocx: originalDocx };
}
@@ -1,5 +0,0 @@
export type OfficeAgentConfig = {
outputDir: string;
command: string | null;
timeout: number | null;
};
@@ -1,15 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow-protocol" },
{ "path": "../workflow-runtime" },
{ "path": "../workflow-util" },
{ "path": "../workflow-util-agent" }
]
}
@@ -1,24 +0,0 @@
# @uncaged/workflow-dashboard
Web dashboard for the Uncaged Workflow engine. Connects to the local
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
## Development
```bash
# Start the local API server (in another terminal)
uncaged-workflow serve
# Start the dashboard dev server
bun run dev
```
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
## Build
```bash
bun run build
```
Output goes to `dist/` — static files ready for CF Pages or any host.
@@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
<script>
(() => {
var t = localStorage.getItem("theme");
if (t === "dark" || (!t && matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -1,43 +0,0 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"files": [
"dist",
"package.json"
],
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"react-router": "^7.15.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.11"
}
}
@@ -1,372 +0,0 @@
import { createFilter, type Plugin } from "vite";
type LimitLineOverride = {
files: string;
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type LimitLineOptions = {
maxReactFCLines: number;
maxFileLines: number;
include: RegExp;
exclude: RegExp | null;
overrides: Array<LimitLineOverride>;
};
const DEFAULT_OPTIONS: LimitLineOptions = {
maxReactFCLines: 300,
maxFileLines: 600,
include: /\.[tj]sx$/,
exclude: null,
overrides: [],
};
type ResolvedLimits = {
maxReactFCLines: number | null;
maxFileLines: number | null;
};
type ComponentInfo = {
name: string;
startLine: number;
lineCount: number;
};
const PASCAL_CASE = /^[A-Z][A-Za-z0-9]*$/;
// --- AST types (Rolldown ESTree subset) ---
type Identifier = {
type: "Identifier";
name: string;
};
type MemberExpression = {
type: "MemberExpression";
object: AstExpression;
property: Identifier;
};
type CallExpression = {
type: "CallExpression";
callee: AstExpression;
arguments: Array<AstExpression>;
};
type AstExpression =
| Identifier
| MemberExpression
| CallExpression
| {
type: string;
[key: string]: unknown;
};
type VariableDeclarator = {
id: Identifier | null;
init: AstExpression | null;
};
type AstStatement = {
type: string;
id: Identifier | null;
declaration: AstStatement | null;
declarations: Array<VariableDeclarator>;
body: Array<AstStatement>;
[key: string]: unknown;
};
type AstProgram = {
type: "Program";
body: Array<AstStatement>;
};
// --- AST helpers ---
function isFunctionLike(node: AstExpression): boolean {
return node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression";
}
const WRAPPER_NAMES = new Set(["memo", "forwardRef", "lazy"]);
function isWrapperCall(node: AstExpression): boolean {
if (node.type !== "CallExpression") return false;
const call = node as CallExpression;
const callee = call.callee;
if (callee.type === "Identifier") {
return WRAPPER_NAMES.has((callee as Identifier).name);
}
if (callee.type === "MemberExpression") {
const member = callee as MemberExpression;
return member.property.type === "Identifier" && WRAPPER_NAMES.has(member.property.name);
}
return false;
}
function extractComponentNames(ast: AstProgram): Array<string> {
const names: Array<string> = [];
for (const node of ast.body) {
if (node.type === "FunctionDeclaration" && node.id && PASCAL_CASE.test(node.id.name)) {
names.push(node.id.name);
continue;
}
if (node.type === "ExportNamedDeclaration" && node.declaration) {
const decl = node.declaration;
if (decl.type === "FunctionDeclaration" && decl.id && PASCAL_CASE.test(decl.id.name)) {
names.push(decl.id.name);
continue;
}
if (decl.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(decl, names);
continue;
}
}
if (node.type === "VariableDeclaration") {
collectNamesFromVarDeclaration(node, names);
}
}
return names;
}
function collectNamesFromVarDeclaration(node: AstStatement, names: Array<string>): void {
for (const declarator of node.declarations ?? []) {
if (!declarator.id || !PASCAL_CASE.test(declarator.id.name) || !declarator.init) continue;
const init = declarator.init;
if (isFunctionLike(init)) {
names.push(declarator.id.name);
} else if (isWrapperCall(init)) {
const args = (init as CallExpression).arguments;
if (args.length > 0 && isFunctionLike(args[0])) {
names.push(declarator.id.name);
}
}
}
}
// --- Source measurement ---
function measureComponentInSource(name: string, lines: Array<string>): ComponentInfo | null {
const fnPattern = new RegExp(`^(?:export\\s+)?function\\s+${name}\\s*[(<]`);
const varPattern = new RegExp(`^(?:export\\s+)?const\\s+${name}\\s*[=:]`);
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trimStart();
const isFnDecl = fnPattern.test(trimmed);
const isVarDecl = varPattern.test(trimmed);
if (!isFnDecl && !isVarDecl) continue;
if (isFnDecl) {
const result = measureFromParams(i, lines);
if (result) return { ...result, name };
return null;
}
const result = measureFromArrow(i, lines);
if (result) return { ...result, name };
return null;
}
return null;
}
// function Foo(...) { ... } — skip params via parens, then brace-match the body
function measureFromParams(startLine: number, lines: Array<string>): ComponentInfo | null {
let parenDepth = 0;
let pastParams = false;
let braceDepth = 0;
for (let j = startLine; j < lines.length; j++) {
for (const ch of lines[j]) {
if (!pastParams) {
if (ch === "(") parenDepth++;
else if (ch === ")") {
parenDepth--;
if (parenDepth === 0) pastParams = true;
}
} else {
if (ch === "{") braceDepth++;
else if (ch === "}") {
braceDepth--;
if (braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
}
return null;
}
// const Foo = (...) => { ... } / const Foo = memo((...) => { ... })
// Find `=>` first, then brace-match from there to skip type annotations in params
function measureFromArrow(startLine: number, lines: Array<string>): ComponentInfo | null {
let arrowFound = false;
let braceDepth = 0;
let foundBrace = false;
for (let j = startLine; j < lines.length; j++) {
const line = lines[j];
for (let c = 0; c < line.length; c++) {
if (!arrowFound) {
if (line[c] === "=" && line[c + 1] === ">") {
arrowFound = true;
c++;
}
continue;
}
if (line[c] === "{") {
braceDepth++;
foundBrace = true;
} else if (line[c] === "}") {
braceDepth--;
if (foundBrace && braceDepth === 0) {
return { name: "", startLine: startLine + 1, lineCount: j - startLine + 1 };
}
}
}
}
return null;
}
// --- Config resolution ---
function createLimitResolver(options: LimitLineOptions): (id: string) => ResolvedLimits {
const matchers = options.overrides.map((override) => ({
match: createFilter(override.files),
maxReactFCLines: override.maxReactFCLines,
maxFileLines: override.maxFileLines,
}));
return (id: string): ResolvedLimits => {
let maxReactFCLines: number | null = options.maxReactFCLines;
let maxFileLines: number | null = options.maxFileLines;
for (const matcher of matchers) {
if (matcher.match(id)) {
maxReactFCLines = matcher.maxReactFCLines;
maxFileLines = matcher.maxFileLines;
}
}
return { maxReactFCLines, maxFileLines };
};
}
function shouldProcess(id: string, options: LimitLineOptions): boolean {
return (
options.include.test(id) &&
!id.includes("node_modules") &&
(options.exclude === null || !options.exclude.test(id))
);
}
// --- Plugin ---
function viteLimitLinePlugin(userOptions: Partial<LimitLineOptions> = {}): Array<Plugin> {
const options: LimitLineOptions = {
...DEFAULT_OPTIONS,
...userOptions,
overrides: userOptions.overrides ?? [],
};
const resolve = createLimitResolver(options);
const rawCodeCache = new Map<string, string>();
return [
{
name: "vite-plugin-limit-line:pre",
enforce: "pre",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
rawCodeCache.set(id, code);
const limits = resolve(id);
if (limits.maxFileLines === null) return null;
const totalLines = code.split("\n").length;
if (totalLines > limits.maxFileLines) {
this.error(
[
`[vite-limit-line] File too long: ${totalLines} lines (limit: ${limits.maxFileLines})`,
` file: ${id}`,
"",
"How to fix:",
" Split this file into smaller modules — extract related types, helpers,",
" or sub-components into separate files and re-export from an index.ts.",
].join("\n"),
);
}
return null;
},
},
{
name: "vite-plugin-limit-line:fc",
transform(code, id) {
if (!shouldProcess(id, options)) return null;
const limits = resolve(id);
if (limits.maxReactFCLines === null) return null;
const ast = this.parse(code) as unknown as AstProgram;
const componentNames = extractComponentNames(ast);
if (componentNames.length === 0) return null;
const raw = rawCodeCache.get(id) ?? code;
rawCodeCache.delete(id);
const rawLines = raw.split("\n");
const maxFCLines = limits.maxReactFCLines;
const violations: Array<ComponentInfo> = [];
for (const name of componentNames) {
const info = measureComponentInSource(name, rawLines);
if (info && info.lineCount > maxFCLines) {
violations.push(info);
}
}
if (violations.length > 0) {
const details = violations
.map(
(v) =>
` ${v.name} (line ${v.startLine}): ${v.lineCount} lines (limit: ${maxFCLines})`,
)
.join("\n");
this.error(
[
`[vite-limit-line] React component too long in ${id}:`,
details,
"",
"How to fix:",
" Break each oversized component into smaller ones. Extract reusable",
" sections into child components, move complex logic into custom hooks,",
" and keep each component focused on a single responsibility.",
].join("\n"),
);
}
return null;
},
buildEnd() {
rawCodeCache.clear();
},
},
];
}
export type { LimitLineOptions, LimitLineOverride };
export { viteLimitLinePlugin };
@@ -1,38 +0,0 @@
import { useState } from "react";
import { Navigate, Outlet, useParams } from "react-router";
import { clearApiKey, hasApiKey } from "./api.ts";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { useTheme } from "./hooks/use-theme.tsx";
export function Layout() {
const [authed, setAuthed] = useState(hasApiKey());
const { client } = useParams();
const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) {
return <Navigate to="/login" replace />;
}
return (
<div className="flex h-screen bg-background">
<Sidebar
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
<Outlet />
</div>
</main>
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
</div>
);
}
@@ -1,31 +0,0 @@
import { Loader2, Users } from "lucide-react";
import { Navigate } from "react-router";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function ClientRedirect() {
const { status, data } = useFetch(() => listClients(), []);
if (status === "loading") {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading clients...</p>
</div>
);
}
if (status === "ok" && data.length > 0) {
return <Navigate to={`/${data[0].name}/threads`} replace />;
}
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No client selected</p>
<p className="text-xs text-muted-foreground">
Select a client from the sidebar to get started.
</p>
</div>
);
}
@@ -1,101 +0,0 @@
import { AlertCircle, Loader2, Moon, Settings, Sun } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { setApiKey } from "../api.ts";
import { useTheme } from "../hooks/use-theme.tsx";
import { Button } from "./ui/button.tsx";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card.tsx";
import { Input } from "./ui/input.tsx";
export function LoginPage() {
const navigate = useNavigate();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { theme, toggleTheme } = useTheme();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!key.trim()) return;
setLoading(true);
setError(null);
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
headers: { Authorization: `Bearer ${key.trim()}` },
});
if (res.status === 401) {
setError("Invalid API key");
setLoading(false);
return;
}
if (!res.ok) {
setError(`Server error: ${res.status}`);
setLoading(false);
return;
}
} catch (err) {
setError(`Connection failed: ${err instanceof Error ? err.message : String(err)}`);
setLoading(false);
return;
}
setApiKey(key.trim());
navigate("/", { replace: true });
}
return (
<div className="min-h-screen flex items-center justify-center bg-background relative">
<Button
variant="ghost"
size="icon"
className="absolute top-4 right-4 transition-colors duration-200"
onClick={toggleTheme}
>
{theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</Button>
<Card className="w-full max-w-sm shadow-lg transition-all duration-200 hover:shadow-xl hover:border-primary/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl tracking-tight">
<Settings className="h-5 w-5" />
Workflow Dashboard
</CardTitle>
<CardDescription>Enter your API key to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="transition-all duration-200"
/>
{error && (
<p className="text-xs text-destructive flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !key.trim()}
className="w-full transition-all duration-200"
>
{loading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Verifying
</span>
) : (
"Login"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -1,130 +0,0 @@
import { CheckCircle2, Clock, MessageSquare, Rocket, User, XCircle } from "lucide-react";
import type { RoleRecord, ThreadRecord, ThreadStartRecord, WorkflowResultRecord } from "../api.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
const ROLE_HUES = [262, 210, 35, 150, 330, 180, 15, 280, 55, 195, 345, 120, 240, 75, 305];
function roleHue(role: string): number {
let hash = 0;
for (let i = 0; i < role.length; i++) {
hash = (hash * 31 + role.charCodeAt(i)) | 0;
}
return ROLE_HUES[Math.abs(hash) % ROLE_HUES.length];
}
function roleBadgeStyle(role: string): { backgroundColor: string; borderColor: string } {
const hue = roleHue(role);
return {
backgroundColor: `oklch(0.58 0.12 ${hue} / 0.85)`,
borderColor: `oklch(0.58 0.12 ${hue} / 0.25)`,
};
}
function formatTime(ts: number | null): string | null {
if (ts === null) return null;
return new Date(ts).toLocaleTimeString();
}
function StartCard({ record }: { record: ThreadStartRecord }) {
return (
<Card className="p-4 transition-all duration-200 overflow-hidden relative">
<div className="absolute inset-x-0 top-0 h-0.5 bg-gradient-to-r from-primary/80 via-primary/40 to-transparent" />
<div className="flex items-center gap-2 mb-2">
<Rocket className="h-5 w-5 text-primary" />
<span className="font-semibold text-foreground">{record.workflow}</span>
<Badge variant={record.status === "active" ? "success" : "secondary"}>
{record.status}
</Badge>
</div>
{record.prompt !== null && (
<div className="mt-2 p-3 rounded-md text-sm border-l-2 border-ring bg-muted/50">
<div className="text-xs mb-1 text-muted-foreground flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</Card>
);
}
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const style = roleBadgeStyle(record.role);
return (
<Card
className={cn(
"p-3 text-sm transition-all duration-200 border-l-4",
highlighted && "wf-record-card-highlight",
)}
style={{ borderLeftColor: style.borderColor }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="text-xs px-2 py-0.5 rounded font-mono font-medium text-white shadow-sm inline-flex items-center gap-1"
style={{ backgroundColor: style.backgroundColor }}
>
<User className="h-3 w-3" />
{record.role}
</span>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</Card>
);
}
function ResultCard({ record }: { record: WorkflowResultRecord }) {
const success = record.returnCode === 0;
return (
<Card
className={cn(
"p-4 transition-all duration-200 border-l-4",
success ? "border-l-success" : "border-l-destructive",
)}
>
<div className="flex items-center gap-2 mb-2">
{success ? (
<CheckCircle2 className="h-5 w-5 text-success" />
) : (
<XCircle className="h-5 w-5 text-destructive" />
)}
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<Badge variant="outline" className="font-mono">
exit {record.returnCode}
</Badge>
{formatTime(record.timestamp) !== null && (
<span className="text-xs ml-auto text-muted-foreground tabular-nums flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</Card>
);
}
type RecordCardProps = {
record: ThreadRecord;
highlighted: boolean;
};
export function RecordCard({ record, highlighted }: RecordCardProps) {
switch (record.type) {
case "thread-start":
return <StartCard record={record} />;
case "role":
return <RoleMessage record={record} highlighted={highlighted} />;
case "workflow-result":
return <ResultCard record={record} />;
}
}
@@ -1,97 +0,0 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Button } from "./ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Textarea } from "./ui/textarea.tsx";
type Props = {
client: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function RunDialog({ client, open, onOpenChange }: Props) {
const navigate = useNavigate();
const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!workflow || !prompt) return;
setSubmitting(true);
setError(null);
try {
const result = await runThread(client, workflow, prompt);
onOpenChange(false);
navigate(`/${client}/threads/${result.threadId}`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Run Thread</DialogTitle>
<DialogDescription>Start a new thread on {client}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
Workflow
</label>
<Select value={workflow} onValueChange={setWorkflow}>
<SelectTrigger>
<SelectValue placeholder="Select a workflow..." />
</SelectTrigger>
<SelectContent>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<SelectItem key={w.name} value={w.name}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
Prompt
</label>
<Textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
placeholder="Enter the task prompt..."
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={submitting || !workflow || !prompt}>
{submitting ? "Starting..." : "Run"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,132 +0,0 @@
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router";
import type { ClientEndpoint } from "../api.ts";
import { listClients } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Button } from "./ui/button.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select.tsx";
import { Separator } from "./ui/separator.tsx";
type Props = {
onLogout: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
};
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
const { client } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { status, data } = useFetch(() => listClients(), []);
const clients: ClientEndpoint[] = status === "ok" ? data : [];
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: Zap },
{ key: "workflows" as const, label: "Workflows", icon: Package },
];
return (
<aside className="w-56 border-r border-border flex flex-col bg-sidebar">
<div className="p-4 border-b border-primary/20">
<h1 className="text-xl font-bold text-foreground tracking-tight">Workflow</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">Dashboard</p>
</div>
<div className="px-3 py-3">
<label
className="block text-xs font-medium mb-1.5 text-muted-foreground"
htmlFor="client-select"
>
Client
</label>
{status === "loading" ? (
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</div>
) : clients.length === 0 ? (
<div className="h-9 rounded-md border border-input bg-transparent px-3 py-2 text-xs text-muted-foreground flex items-center">
No clients online
</div>
) : (
<Select
value={client ?? ""}
onValueChange={(name) => {
if (name) navigate(`/${name}/${view}`);
}}
>
<SelectTrigger className="h-8 text-xs transition-colors duration-200">
<SelectValue placeholder="Select client…" />
</SelectTrigger>
<SelectContent>
{clients.map((a) => (
<SelectItem key={a.name} value={a.name} className="text-xs">
<span className="flex items-center gap-2">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
a.status === "online" ? "bg-success animate-pulse" : "bg-destructive",
)}
/>
{a.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<Separator />
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<Button
key={item.key}
variant={view === item.key ? "secondary" : "ghost"}
size="sm"
className={cn(
"w-full justify-start gap-2 transition-colors duration-200",
view === item.key
? "text-foreground border-l-2 border-primary rounded-l-none"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => {
if (client) navigate(`/${client}/${item.key}`);
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
))}
</nav>
<Separator />
<div className="p-2 space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onToggleTheme}
>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground transition-colors duration-200"
onClick={onLogout}
>
<LogOut className="h-4 w-4" />
Logout
</Button>
</div>
</aside>
);
}
@@ -1,88 +0,0 @@
import { AlertCircle, Clock, Loader2, Workflow, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Badge } from "./ui/badge.tsx";
import { Card } from "./ui/card.tsx";
function statusVariant(status: string): "success" | "destructive" | "secondary" {
if (status === "completed") return "success";
if (status === "failed") return "destructive";
return "secondary";
}
export function ThreadList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const { status, data, error } = useFetch(() => listThreads(client), [client]);
if (status === "loading")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading threads...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
const threads = [...data.threads].sort((a, b) => {
if (!a.startedAt && !b.startedAt) return 0;
if (!a.startedAt) return 1;
if (!b.startedAt) return -1;
return b.startedAt.localeCompare(a.startedAt);
});
return (
<div>
<h2 className="text-xl font-semibold tracking-tight mb-4">Threads</h2>
{threads.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Zap className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No threads</p>
<p className="text-xs text-muted-foreground">
Run a workflow to create your first thread.
</p>
</div>
) : (
<div className="space-y-2">
{threads.map((t) => (
<Card
key={t.threadId}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/threads/${t.threadId}`)}
>
<div className="flex items-center justify-between">
<code className="font-mono text-sm text-foreground">{t.threadId}</code>
{t.status && (
<Badge variant={statusVariant(t.status)} className="text-xs">
{t.status}
</Badge>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1 font-medium text-foreground flex items-center gap-1.5">
<Workflow className="h-3.5 w-3.5 text-muted-foreground" />
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
{t.startedAt}
</p>
)}
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,30 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
success: "border-transparent bg-success text-success-foreground shadow",
warning: "border-transparent bg-warning text-warning-foreground shadow",
},
},
defaultVariants: {
variant: "default",
},
},
);
export type BadgeProps = HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -1,45 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "border border-success text-success hover:bg-success/10",
warning: "border border-warning text-warning hover:bg-warning/10",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
@@ -1,36 +0,0 @@
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
function CardTitle({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
function CardDescription({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex items-center p-6 pt-0", className)} {...props} />;
}
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
@@ -1,7 +0,0 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleContent, CollapsibleTrigger };
@@ -1,104 +0,0 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import type { ComponentPropsWithoutRef, HTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
function DialogOverlay({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -1,17 +0,0 @@
import type { InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Input({ className, type, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Input };
@@ -1,70 +0,0 @@
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useRef,
useState,
} from "react";
import { cn } from "../../lib/utils.ts";
type Props = {
defaultWidth: number;
minWidth: number;
maxWidth: number;
className: string | null;
style: CSSProperties | null;
children: React.ReactNode;
};
export function ResizablePanel({
defaultWidth,
minWidth,
maxWidth,
className,
style,
children,
}: Props) {
const [width, setWidth] = useState(defaultWidth);
const dragging = useRef(false);
const startX = useRef(0);
const startW = useRef(0);
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startW.current = width;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[width],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
const delta = e.clientX - startX.current;
const next = Math.min(maxWidth, Math.max(minWidth, startW.current + delta));
setWidth(next);
},
[minWidth, maxWidth],
);
const onPointerUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<div className={cn("relative shrink-0", className)} style={{ ...style, width }}>
{children}
<div
className="absolute top-0 -right-1 w-2 h-full cursor-col-resize z-10 group"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="absolute inset-y-0 left-1/2 w-px bg-border opacity-0 group-hover:opacity-100 transition-opacity duration-150" />
</div>
</div>
);
}
@@ -1,42 +0,0 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function ScrollArea({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -1,148 +0,0 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
function SelectTrigger({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
@@ -1,25 +0,0 @@
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -1,69 +0,0 @@
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
function TableFooter({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tfoot
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
);
}
function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn(
"border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
return <caption className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />;
}
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow };
@@ -1,16 +0,0 @@
import type { TextareaHTMLAttributes } from "react";
import { cn } from "../../lib/utils.ts";
function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -1,28 +0,0 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type { ComponentPropsWithoutRef } from "react";
import { cn } from "../../lib/utils.ts";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({
className,
sideOffset = 4,
...props
}: ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
@@ -1,423 +0,0 @@
import {
AlertCircle,
ArrowLeft,
ChevronDown,
GitBranch,
Hash,
Layers,
Loader2,
User,
} from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import type { WorkflowDetail as WorkflowDetailData, WorkflowRoleDescriptor } from "../api.ts";
import { getWorkflowDetail } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { cn } from "../lib/utils.ts";
import { Markdown } from "./markdown.tsx";
import { Button } from "./ui/button.tsx";
import { Card } from "./ui/card.tsx";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible.tsx";
import { ResizablePanel } from "./ui/resizable-panel.tsx";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./ui/table.tsx";
import { type NodeState, WorkflowGraph } from "./workflow-graph/index.ts";
const ROLE_BORDER_COLORS = [
"border-l-blue-400/60",
"border-l-emerald-400/60",
"border-l-amber-400/60",
"border-l-violet-400/60",
"border-l-rose-400/60",
"border-l-cyan-400/60",
"border-l-orange-400/60",
"border-l-teal-400/60",
];
function roleBorderColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return ROLE_BORDER_COLORS[Math.abs(hash) % ROLE_BORDER_COLORS.length];
}
function versionCount(detail: WorkflowDetailData): number {
return detail.history.length + 1;
}
type SchemaRow = {
key: string;
name: string;
type: string;
description: string;
depth: number;
prefix: string;
isVariantHeader: boolean;
};
function resolveType(prop: Record<string, unknown>): string {
if (prop.type === "array") {
const items = prop.items as Record<string, unknown> | undefined;
if (items !== undefined) {
const itemType = String(items.type ?? "unknown");
return `${itemType}[]`;
}
return "array";
}
return String(prop.type ?? "unknown");
}
function flattenSchema(
schema: Record<string, unknown>,
depth: number,
parentPrefix: string,
keyPrefix: string,
): SchemaRow[] {
const rows: SchemaRow[] = [];
const oneOf = schema.oneOf as Array<Record<string, unknown>> | undefined;
if (Array.isArray(oneOf) && oneOf.length > 0) {
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;
}
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 rows;
}
function flattenProperty(
name: string,
prop: Record<string, unknown>,
depth: number,
parentPrefix: string,
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;
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}-`,
),
);
}
return rows;
}
function RoleCard({ roleName, role }: { roleName: string; role: WorkflowRoleDescriptor }) {
const rows = flattenSchema(role.schema, 0, "", `${roleName}-`);
const [promptOpen, setPromptOpen] = useState(false);
return (
<Card id={`role-${roleName}`} className={cn("p-4 border-l-4", roleBorderColor(roleName))}>
<h4 className="text-sm font-semibold font-mono mb-1 text-foreground flex items-center gap-1.5">
<User className="h-3.5 w-3.5 text-muted-foreground" />
{roleName}
</h4>
{role.description !== "" && (
<p className="text-xs mb-3 text-muted-foreground">{role.description}</p>
)}
{role.systemPrompt !== "" && (
<Collapsible open={promptOpen} onOpenChange={setPromptOpen} className="mb-3">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1 h-7 px-2 text-[10px] uppercase tracking-wider text-muted-foreground bg-muted/50 rounded-md transition-all duration-200"
>
<ChevronDown
className={cn("h-3 w-3 transition-transform", promptOpen && "rotate-180")}
/>
System Prompt
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 p-2 rounded-md overflow-y-auto text-xs bg-background border border-border max-h-[300px]">
<Markdown content={role.systemPrompt} />
</div>
</CollapsibleContent>
</Collapsible>
)}
{rows.length > 0 && (
<div>
<p className="text-[10px] uppercase tracking-wider mb-1 font-medium text-muted-foreground">
Meta Schema
</p>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Field</TableHead>
<TableHead className="text-xs">Type</TableHead>
<TableHead className="text-xs">Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow
key={r.key}
className={cn(r.isVariantHeader ? "border-b-0" : "", "even:bg-muted/30")}
>
<TableCell
className={cn(
"font-mono whitespace-pre text-xs",
r.isVariantHeader ? "italic text-muted-foreground" : "text-foreground",
)}
>
{r.name}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{r.type}
</TableCell>
<TableCell className="text-xs">
{r.description || (r.isVariantHeader ? "" : "—")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{rows.length === 0 && Object.keys(role.schema).length > 0 && (
<pre className="text-[10px] font-mono p-2 rounded-md overflow-x-auto bg-background text-muted-foreground">
{JSON.stringify(role.schema, null, 2)}
</pre>
)}
</Card>
);
}
export function WorkflowDetail() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const workflowName = params.workflowName as string;
const { status, data, error } = useFetch(
() => getWorkflowDetail(client, workflowName),
[client, workflowName],
);
const [highlightedRole, setHighlightedRole] = useState<string | null>(null);
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const detail = status === "ok" ? data : null;
const descriptor = detail?.descriptor ?? null;
const roleEntries = descriptor !== null ? Object.entries(descriptor.roles) : [];
const edgeCount = descriptor !== null ? descriptor.graph.edges.length : 0;
const hasGraph = descriptor !== null && edgeCount > 0;
const allLitStates = useMemo(() => {
const m = new Map<string, NodeState>();
m.set("__start__", "completed");
m.set("__end__", "completed");
for (const [name] of roleEntries) {
m.set(name, "completed");
}
return m;
}, [roleEntries]);
function handleGraphNodeClick(nodeId: string) {
const el = document.getElementById(`role-${nodeId}`);
if (el === null) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
if (highlightTimerRef.current !== null) clearTimeout(highlightTimerRef.current);
setHighlightedRole(nodeId);
highlightTimerRef.current = setTimeout(() => {
setHighlightedRole(null);
highlightTimerRef.current = null;
}, 1500);
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<Button
variant="ghost"
className="gap-1.5 px-2 text-muted-foreground hover:text-foreground transition-all duration-200"
onClick={() => navigate(`/${client}/workflows`)}
>
<ArrowLeft className="h-4 w-4" />
Back to workflows
</Button>
</div>
<h2 className="text-xl font-semibold mb-4 font-mono tracking-tight">{workflowName}</h2>
{status === "loading" && (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading workflow...</span>
</div>
)}
{status === "error" && (
<div className="flex items-center justify-center gap-2 py-12 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>Error: {error}</span>
</div>
)}
{detail !== null && (
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 160px)" }}>
{hasGraph && (
<ResizablePanel
defaultWidth={360}
minWidth={240}
maxWidth={560}
className={null}
style={{
position: "sticky",
top: 16,
height: "calc(100vh - 160px)",
alignSelf: "flex-start",
}}
>
<Card className="h-full flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground bg-muted/50">
<span className="font-mono flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
Workflow graph
</span>
<span>
{edgeCount} edge{edgeCount === 1 ? "" : "s"}
</span>
</div>
<div className="flex-1">
<WorkflowGraph
graph={descriptor.graph}
roles={descriptor.roles}
nodeStates={allLitStates}
onNodeClick={handleGraphNodeClick}
/>
</div>
</Card>
</ResizablePanel>
)}
<div className="flex-1 min-w-0 space-y-4">
<Card className="p-4">
<div className="rounded-md bg-muted/30 px-3 py-2 mb-3">
<p className="text-sm whitespace-pre-wrap text-foreground">
{descriptor !== null && descriptor.description !== ""
? descriptor.description
: "—"}
</p>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs font-mono">
<Hash className="h-3 w-3" />
<span className="text-foreground">{detail.hash}</span>
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
<GitBranch className="h-3 w-3" />
{versionCount(detail)} version{versionCount(detail) !== 1 ? "s" : ""}
</span>
{roleEntries.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs">
<User className="h-3 w-3" />
{roleEntries.length} role{roleEntries.length !== 1 ? "s" : ""}
</span>
)}
</div>
</Card>
{roleEntries.map(([name, role]) => (
<div
key={name}
className={cn(
"rounded-lg transition-shadow duration-300",
highlightedRole === name && "ring-2 ring-ring",
)}
>
<RoleCard roleName={name} role={role} />
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -1,68 +0,0 @@
import { AlertCircle, Clock, Hash, Loader2, Package } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
import { Card } from "./ui/card.tsx";
export function WorkflowList() {
const params = useParams();
const navigate = useNavigate();
const client = params.client as string;
const { status, data, error } = useFetch(() => listWorkflows(client), [client]);
if (status === "loading")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Loading workflows...</p>
</div>
);
if (status === "error")
return (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">Error: {error}</p>
</div>
);
const workflows = data.workflows;
return (
<div>
<h2 className="text-xl font-semibold tracking-tight mb-4">Workflows</h2>
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-3">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="text-sm font-medium">No workflows</p>
<p className="text-xs text-muted-foreground">Register a workflow to get started.</p>
</div>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<Card
key={w.name}
className="p-4 cursor-pointer hover:bg-accent/50 hover:shadow-sm transition-all duration-200"
onClick={() => navigate(`/${client}/workflows/${w.name}`)}
>
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
{w.name}
</span>
<code className="text-xs mt-1 font-mono text-muted-foreground flex items-center gap-1.5">
<Hash className="h-3 w-3" />
{w.hash !== null ? w.hash : "—"}
</code>
{w.timestamp !== null ? (
<span className="text-xs mt-1 text-muted-foreground flex items-center gap-1.5">
<Clock className="h-3 w-3" />
Updated {new Date(w.timestamp).toLocaleString()}
</span>
) : null}
</Card>
))}
</div>
)}
</div>
);
}
@@ -1,74 +0,0 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
export type Theme = "light" | "dark";
type ThemeContextValue = {
theme: Theme;
setTheme: (t: Theme) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
function getStoredTheme(): Theme | null {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return null;
}
function getSystemTheme(): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme): void {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme() ?? getSystemTheme());
useEffect(() => {
applyTheme(theme);
}, [theme]);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
function handler() {
if (getStoredTheme() === null) {
const sys = getSystemTheme();
setThemeState(sys);
applyTheme(sys);
}
}
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem("theme", t);
setThemeState(t);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === "dark" ? "light" : "dark";
localStorage.setItem("theme", next);
applyTheme(next);
return next;
});
}, []);
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (ctx === null) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
@@ -1,126 +0,0 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius: 0.625rem;
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-success: hsl(var(--success));
--color-success-foreground: hsl(var(--success-foreground));
--color-warning: hsl(var(--warning));
--color-warning-foreground: hsl(var(--warning-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-sidebar: hsl(var(--sidebar));
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
}
:root {
--radius: 0.625rem;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--success: 160 60% 40%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 0%;
--sidebar: 0 0% 98%;
--sidebar-foreground: 240 3.8% 46.1%;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 6% 6.5%;
--card-foreground: 0 0% 98%;
--popover: 240 6% 6.5%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--success: 160 60% 45%;
--success-foreground: 0 0% 98%;
--warning: 38 92% 50%;
--warning-foreground: 0 0% 0%;
--sidebar: 240 6% 6.5%;
--sidebar-foreground: 240 5% 64.9%;
}
body {
margin: 0;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
@keyframes wf-node-pulse {
0%,
100% {
box-shadow: 0 0 0 0 hsl(var(--ring) / 0.55);
}
50% {
box-shadow: 0 0 0 6px hsl(var(--ring) / 0);
}
}
.wf-node-pulse {
animation: wf-node-pulse 1.6s ease-in-out infinite;
}
@keyframes wf-record-card-highlight {
0% {
border-color: hsl(var(--ring));
}
35% {
border-color: hsl(var(--ring));
}
100% {
border-color: hsl(var(--border));
}
}
.wf-record-card-highlight {
animation: wf-record-card-highlight 1.5s ease-out forwards;
}
@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
@@ -1,17 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router";
import { ThemeProvider } from "./hooks/use-theme.tsx";
import "./index.css";
import { router } from "./router.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</StrictMode>,
);
}
@@ -1,45 +0,0 @@
import { createHashRouter, redirect } from "react-router";
import { Layout } from "./app.tsx";
import { ClientRedirect } from "./components/client-redirect.tsx";
import { LoginPage } from "./components/login.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowDetail } from "./components/workflow-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
export const router = createHashRouter([
{
path: "/login",
Component: LoginPage,
},
{
path: "/",
Component: Layout,
children: [
{
index: true,
Component: ClientRedirect,
},
{
path: ":client/threads",
Component: ThreadList,
},
{
path: ":client/threads/:threadId",
Component: ThreadDetail,
},
{
path: ":client/workflows",
Component: WorkflowList,
},
{
path: ":client/workflows/:workflowName",
Component: WorkflowDetail,
},
{
path: ":client",
loader: ({ params }) => redirect(`/${params.client}/threads`),
},
],
},
]);
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"types": ["vite/client"],
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src", "plugins"]
}
@@ -1,22 +0,0 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteLimitLinePlugin } from "./plugins/vite-limit-line-plugin.js";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [
react(),
tailwindcss(),
...viteLimitLinePlugin({ maxReactFCLines: 300, maxFileLines: 600 }),
],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:7860",
changeOrigin: true,
},
},
},
});
@@ -1,29 +0,0 @@
# @uncaged/workflow-protocol
Shared workflow types, sentinel constants, and `Result` helpers.
## What This Package Does
It defines the cross-package contract for bundles and the engine: thread/step shapes, `WorkflowFn`, agent/extract contexts, descriptor types, and `CasStore` as an interface. Implementations (CAS store, CLI, extract) depend on these types so bundles stay decoupled from Node APIs.
## Key Exports
From `src/index.ts`:
- **Types:** `Result`, `CasStore`, `WorkflowRoleSchema`, `WorkflowRoleDescriptor`, `WorkflowDescriptor`, `RoleMeta`, `RoleOutput`, `StartStep`, `RoleStep`, `ThreadContext`, `ModeratorContext`, `AgentContext`, `ExtractContext`, `WorkflowCompletion`, `WorkflowResult`, `LlmProvider`, `ProviderConfig`, `ResolvedModel`, `WorkflowConfig`, `ExtractFn`, `AgentFn`, `AgentBinding`, `WorkflowRuntime`, `WorkflowFn`, `RoleDefinition`, `Moderator`, `WorkflowDefinition`, `AdvanceOutcome`
- **Constants:** `START`, `END`
- **Functions:** `ok`, `err`
## Dependencies
- **Peer:** `zod` ^4 — used in type positions for schemas (`ExtractFn`, `RoleDefinition`, etc.)
No workspace packages; this is the bottom layer.
## Usage
```typescript
import { END, START, type WorkflowFn, type ThreadContext } from "@uncaged/workflow-protocol";
```
Concrete `WorkflowFn` implementations are built with `@uncaged/workflow-runtime` (`createWorkflow`).
@@ -1,32 +0,0 @@
{
"name": "@uncaged/workflow-protocol",
"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"
},
"./moderator-table.js": {
"bun": "./src/moderator-table.ts",
"types": "./dist/moderator-table.d.ts",
"import": "./dist/moderator-table.js"
}
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0",
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,56 +0,0 @@
// ── Types ──────────────────────────────────────────────────────────
export type {
ContentMerkleNode,
StartNode,
StartNodePayload,
StateNode,
StateNodePayload,
} from "./cas-types.js";
export type {
AdapterBinding,
AdapterFn,
AdvanceOutcome,
AgentContext,
AgentFn,
CasStore,
ExtractFn,
ExtractResult,
FALLBACK,
LlmProvider,
ModeratorCondition,
ModeratorContext,
ModeratorTable,
ModeratorTransition,
ProviderConfig,
ResolvedModel,
Result,
RoleDefinition,
RoleFn,
RoleMeta,
RoleOutput,
RoleResult,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowConfig,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowGraph,
WorkflowGraphEdge,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
// ── Constants ──────────────────────────────────────────────────────
export { END, START } from "./types.js";
// ── Constructor functions ──────────────────────────────────────────
export { err, ok } from "./result.js";
@@ -1,219 +0,0 @@
import type * as z from "zod/v4";
// ── Constants ──────────────────────────────────────────────────────
export const START = "__start__" as const;
export const END = "__end__" as const;
// ── Result ─────────────────────────────────────────────────────────
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
// ── CAS ────────────────────────────────────────────────────────────
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
// ── Workflow Descriptor ────────────────────────────────────────────
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
systemPrompt: string;
schema: WorkflowRoleSchema;
};
/** Serializable routing edges derived from a moderator transition table. */
export type WorkflowGraphEdge = {
from: string;
to: string;
condition: string;
conditionDescription: string | null;
};
export type WorkflowGraph = {
edges: readonly WorkflowGraphEdge[];
};
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
graph: WorkflowGraph;
};
// ── Role & Thread ──────────────────────────────────────────────────
export type RoleMeta = Record<string, Record<string, unknown>>;
export type RoleOutput = {
role: string;
contentHash: string;
meta: Record<string, unknown>;
refs: string[];
childThread: string | null;
};
export type StartStep = {
role: typeof START;
content: string;
meta: Record<string, never>;
timestamp: number;
parentState: string | null;
};
export type RoleStep<M extends RoleMeta> = {
[K in keyof M & string]: {
role: K;
meta: M[K];
contentHash: string;
content: string | null;
refs: string[];
timestamp: number;
};
}[keyof M & string];
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
depth: number;
bundleHash: string;
start: StartStep;
steps: RoleStep<M>[];
};
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: {
name: string;
systemPrompt: string;
};
};
// ── Workflow Completion ────────────────────────────────────────────
export type WorkflowCompletion = {
returnCode: number;
summary: string;
};
export type WorkflowResult = WorkflowCompletion & {
rootHash: string;
};
// ── LLM Provider ───────────────────────────────────────────────────
export type LlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
export type ProviderConfig = {
baseUrl: string;
apiKey: string;
};
export type ResolvedModel = {
baseUrl: string;
apiKey: string;
model: string;
};
export type WorkflowConfig = {
maxDepth: number;
supervisorInterval: number;
providers: Record<string, ProviderConfig>;
models: Record<string, string>;
};
// ── Functions ──────────────────────────────────────────────────────
/** Structured output of the extract phase (RFC v3 content Merkle + artifact refs). */
export type ExtractResult<T extends Record<string, unknown>> = {
meta: T;
contentPayload: string;
refs: string[];
};
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
contentHash: string,
) => Promise<ExtractResult<T>>;
// ── Adapter (replaces Agent) ────────────────────────────────────────
export type RoleResult<T> = { meta: T; childThread: string | null };
export type RoleFn<T> = (ctx: ThreadContext, runtime: WorkflowRuntime) => Promise<RoleResult<T>>;
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).
*/
export type AgentFn<Opt> = (ctx: ThreadContext, options: Opt) => Promise<string>;
export type AdapterBinding = {
adapter: AdapterFn;
overrides: Partial<Record<string, AdapterFn>> | null;
};
// ── Workflow Runtime & Definition ──────────────────────────────────
export type WorkflowRuntime = {
cas: CasStore;
extract: ExtractFn;
};
export type WorkflowFn = (
thread: ThreadContext,
runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
export type RoleDefinition<Meta extends Record<string, unknown>> = {
description: string;
systemPrompt: string;
schema: z.ZodType<Meta>;
};
export type Moderator<M extends RoleMeta> = (
ctx: ModeratorContext<M>,
) => (keyof M & string) | typeof END;
export type WorkflowDefinition<M extends RoleMeta> = {
description: string;
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
table: ModeratorTable<M>;
};
// ── Declarative Moderator Table ────────────────────────────────────
export type ModeratorCondition<M extends RoleMeta> = {
name: string;
description: string;
check: (ctx: ModeratorContext<M>) => boolean;
};
export type FALLBACK = "FALLBACK";
export type ModeratorTransition<M extends RoleMeta> = {
condition: ModeratorCondition<M> | FALLBACK;
role: (keyof M & string) | typeof END;
};
export type ModeratorTable<M extends RoleMeta> = Record<
(keyof M & string) | typeof START,
ModeratorTransition<M>[]
>;
// ── Advance Outcome ────────────────────────────────────────────────
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
@@ -1,93 +0,0 @@
import { describe, expect, test } from "bun:test";
import { tableToModerator } from "@uncaged/workflow-protocol/moderator-table.js";
import { validateWorkflowDescriptor } from "@uncaged/workflow-register";
import { END, type ModeratorContext, type RoleStep, START } from "@uncaged/workflow-runtime";
import { buildDocumentDescriptor } from "../src/descriptor.js";
import { documentTable } from "../src/moderator.js";
import type { DifferMeta, WriterMeta } from "../src/roles/index.js";
import type { DocumentMeta } from "../src/roles.js";
const documentModerator = tableToModerator(documentTable);
function makeCtx(steps: ModeratorContext<DocumentMeta>["steps"]): ModeratorContext<DocumentMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
bundleHash: "TESTHASH00001",
start: { role: START, content: "", meta: {}, timestamp: 0, parentState: null },
steps,
};
}
function writerGenerateStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER001",
meta: {
mode: "generate",
outputDocx: "/out/output.docx",
sourceDocx: null,
} satisfies WriterMeta,
refs: [],
timestamp: 1,
};
}
function writerEditStep(): RoleStep<DocumentMeta> {
return {
role: "writer",
contentHash: "STUBHASHWRITER002",
meta: {
mode: "edit",
outputDocx: "/out/modified.docx",
sourceDocx: "/out/original.docx",
} satisfies WriterMeta,
refs: [],
timestamp: 1,
};
}
function differStep(): RoleStep<DocumentMeta> {
return {
role: "differ",
contentHash: "STUBHASHDIFF001",
meta: {
sourceDocx: "/out/original.docx",
modifiedDocx: "/out/modified.docx",
diffDocx: "/out/diff.docx",
} satisfies DifferMeta,
refs: [],
timestamp: 2,
};
}
describe("documentTable", () => {
test("START → writer", () => {
expect(documentModerator(makeCtx([]))).toBe("writer");
});
test("writer (generate) → END", () => {
expect(documentModerator(makeCtx([writerGenerateStep()]))).toBe(END);
});
test("writer (edit) → differ", () => {
expect(documentModerator(makeCtx([writerEditStep()]))).toBe("differ");
});
test("differ → END", () => {
expect(documentModerator(makeCtx([writerEditStep(), differStep()]))).toBe(END);
});
});
describe("buildDocumentDescriptor", () => {
test("descriptor passes validation", () => {
const descriptor = buildDocumentDescriptor();
expect(() => validateWorkflowDescriptor(descriptor)).not.toThrow();
});
test("descriptor has writer and differ roles", () => {
const descriptor = buildDocumentDescriptor();
expect(Object.keys(descriptor.roles)).toContain("writer");
expect(Object.keys(descriptor.roles)).toContain("differ");
});
});
@@ -1,32 +0,0 @@
{
"name": "@uncaged/workflow-template-document",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"types": "src/index.ts",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow-register": "workspace:^",
"@uncaged/workflow-runtime": "workspace:^",
"zod": "^4.0.0"
},
"devDependencies": {
"@uncaged/workflow-protocol": "workspace:^"
},
"publishConfig": {
"access": "public"
}
}
@@ -1,11 +0,0 @@
import { buildDescriptor } from "@uncaged/workflow-register";
import { documentTable } from "./moderator.js";
import { DOCUMENT_WORKFLOW_DESCRIPTION, documentRoles } from "./roles.js";
export function buildDocumentDescriptor() {
return buildDescriptor({
description: DOCUMENT_WORKFLOW_DESCRIPTION,
roles: documentRoles,
table: documentTable,
});
}
@@ -1,27 +0,0 @@
import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { documentTable } from "./moderator.js";
import { DOCUMENT_WORKFLOW_DESCRIPTION, type DocumentMeta, documentRoles } from "./roles.js";
export { buildDocumentDescriptor } from "./descriptor.js";
export { documentTable } from "./moderator.js";
export {
type DifferMeta,
differMetaSchema,
differRole,
type WriterMeta,
writerMetaSchema,
writerRole,
} from "./roles/index.js";
export {
DOCUMENT_WORKFLOW_DESCRIPTION,
type DocumentMeta,
type DocumentRoles,
documentRoles,
} from "./roles.js";
export type { DocumentStartInput } from "./types.js";
export const documentWorkflowDefinition: WorkflowDefinition<DocumentMeta> = {
description: DOCUMENT_WORKFLOW_DESCRIPTION,
roles: documentRoles,
table: documentTable,
};
@@ -1,27 +0,0 @@
import {
END,
type ModeratorCondition,
type ModeratorTable,
START,
} from "@uncaged/workflow-runtime";
import type { WriterMeta } from "./roles/writer.js";
import type { DocumentMeta } from "./roles.js";
const writerIsEditMode: ModeratorCondition<DocumentMeta> = {
name: "writerIsEditMode",
description: "Writer ran in edit mode and produced a modified document",
check: (ctx) => {
const writerStep = ctx.steps.find((s) => s.role === "writer");
if (writerStep === undefined) return false;
return (writerStep.meta as WriterMeta).mode === "edit";
},
};
export const documentTable: ModeratorTable<DocumentMeta> = {
[START]: [{ condition: "FALLBACK", role: "writer" }],
writer: [
{ condition: writerIsEditMode, role: "differ" },
{ condition: "FALLBACK", role: END },
],
differ: [{ condition: "FALLBACK", role: END }],
};

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