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
180 changed files with 6391 additions and 10092 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`
-1
View File
@@ -9,4 +9,3 @@ bunfig.toml
xiaoju/
solve-issue-entry.ts
packages/workflow-template-develop/develop.esm.js
.DS_Store
+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 });
+2 -5
View File
@@ -8,7 +8,7 @@
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 **21** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
## Package map
@@ -26,13 +26,10 @@ Grouped by responsibility (npm name → folder).
| 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-office``workflow-agent-office` | `AdapterFn` via `office-agent` CLI; generates or edits Word documents, stores outputs per threadId. |
| | `@uncaged/workflow-agent-docx-diff``workflow-agent-docx-diff` | `AdapterFn` via `docx-diff` CLI; produces Word-format diff reports for document edit workflows. |
| | `@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. |
| | `@uncaged/workflow-template-document``workflow-template-document` | Document generation/editing workflow definition (writer + differ roles, moderator table, descriptor). |
| Dashboard | `@uncaged/workflow-dashboard``workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
## Dependency graph (workspace packages)
@@ -268,4 +265,4 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
| **Single-file ESM** | Hash = version, self-contained bundle |
| **No daemon** | OS handles process lifecycle |
| **Crockford Base32** | Filesystem-safe, readable, compact |
| **21-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
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` 须为本机有效绝对路径)
-527
View File
@@ -1,527 +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 读 systemPrompt / outputSchema
- agent-kit 组装完整 prompt(role systemPrompt + 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 中,只有 outputSchema 独立为 CAS 节点(方便 json-cas 校验)。
```yaml
type: <workflow-schema-hash>
payload:
name: "solve-issue"
description: "End-to-end issue resolution"
roles:
planner:
description: "Creates implementation plan"
systemPrompt: "You are a planning agent..."
outputSchema: "5GWKR8TN1V3JA" # cas_ref → JSON Schema 节点(json-cas 内置)
developer:
description: "Implements code changes"
systemPrompt: "You are a developer agent..."
outputSchema: "8CNWT4KR6D1HV" # cas_ref → JSON Schema 节点
reviewer:
description: "Reviews code changes"
systemPrompt: "You are a code reviewer..."
outputSchema: "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 的 `outputSchema` 是独立的 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 的 outputSchema)
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 outputSchema 的 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-uwf/ # @uncaged/cli-uwf — uwf CLI(thread/workflow 命令)
├── uwf-moderator/ # @uncaged/uwf-moderator — JSONata moderator 引擎
├── uwf-agent-kit/ # @uncaged/uwf-agent-kit — Agent CLI 框架(含 extractor)
├── uwf-agent-hermes/ # @uncaged/uwf-agent-hermes — uwf-hermes CLI
├── uwf-agent-cursor/ # @uncaged/uwf-agent-cursor — uwf-cursor CLI
└── uwf-protocol/ # @uncaged/uwf-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 outputSchema)
detail: CasRef; // cas_ref → 执行详情(content node / 子 workflow terminal StepNode)
agent: string; // 实际使用的 agent 命令(纯字符串)
};
```
### 4.2 Workflow 定义
```typescript
type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: 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[]>
```
+4
View File
@@ -4,6 +4,10 @@
"workspaces": [
"packages/*"
],
"overrides": {
"@uncaged/json-cas": "^0.1.0",
"@uncaged/json-cas-workflow": "^0.1.0"
},
"scripts": {
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@uncaged/cli-uwf",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf": "./src/cli.ts"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/uwf-agent-kit": "workspace:^",
"@uncaged/uwf-moderator": "workspace:^",
"@uncaged/uwf-protocol": "workspace:^",
"@uncaged/workflow-util": "workspace:^",
"commander": "^14.0.3",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
},
"scripts": {
"test": "bun test"
},
"publishConfig": {
"access": "public"
}
}
-307
View File
@@ -1,307 +0,0 @@
#!/usr/bin/env bun
import { Command } from "commander";
import {
cmdThreadFork,
cmdThreadKill,
cmdThreadList,
cmdThreadShow,
cmdThreadStart,
cmdThreadStep,
cmdThreadSteps,
} from "./commands/thread.js";
import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js";
import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js";
import {
cmdCasGet,
cmdCasHas,
cmdCasPut,
cmdCasReindex,
cmdCasRefs,
cmdCasSchemaGet,
cmdCasSchemaList,
cmdCasWalk,
} from "./commands/cas.js";
import { resolveStorageRoot } from "./store.js";
import { type OutputFormat, formatOutput } from "./format.js";
function writeOutput(data: unknown): void {
const fmt = program.opts().format as OutputFormat;
process.stdout.write(`${formatOutput(data, fmt)}\n`);
}
function runAction(action: () => Promise<void>): void {
action().catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
});
}
const program = new Command();
program.name("uwf").description("Stateless workflow CLI");
program.option("--format <fmt>", "Output format: json or yaml", "json");
const workflow = program.command("workflow").description("Workflow registry and CAS");
workflow
.command("put")
.description("Register a workflow from YAML")
.argument("<file>", "Workflow YAML file")
.action((file: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowPut(storageRoot, file);
writeOutput(result);
});
});
workflow
.command("show")
.description("Show a workflow by name or CAS hash")
.argument("<id>", "Workflow name or hash")
.action((id: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowShow(storageRoot, id);
writeOutput(result);
});
});
workflow
.command("list")
.description("List registered workflows")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdWorkflowList(storageRoot);
writeOutput(result);
});
});
const thread = program.command("thread").description("Thread lifecycle and execution");
thread
.command("start")
.description("Create a thread without executing")
.argument("<workflow>", "Workflow name or hash")
.requiredOption("-p, --prompt <text>", "User prompt")
.action((workflow: string, opts: { prompt: string }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadStart(storageRoot, workflow, opts.prompt);
writeOutput(result);
});
});
thread
.command("step")
.description("Execute one step")
.argument("<thread-id>", "Thread ULID")
.option("--agent <cmd>", "Override agent command")
.action((threadId: string, opts: { agent: string | undefined }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const agentOverride = opts.agent ?? null;
const result = await cmdThreadStep(storageRoot, threadId, agentOverride);
writeOutput(result);
});
});
thread
.command("show")
.description("Show thread head pointer")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadShow(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("list")
.description("List active threads")
.option("--all", "Include archived threads")
.action((opts: { all: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadList(storageRoot, opts.all);
writeOutput(result);
});
});
thread
.command("kill")
.description("Terminate and archive a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadKill(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("steps")
.description("List all steps in a thread")
.argument("<thread-id>", "Thread ULID")
.action((threadId: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadSteps(storageRoot, threadId);
writeOutput(result);
});
});
thread
.command("fork")
.description("Fork a thread from a specific step")
.argument("<thread-id>", "Thread ULID")
.argument("<step-hash>", "CAS hash of the step to fork from")
.action((threadId: string, stepHash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
const result = await cmdThreadFork(storageRoot, threadId, stepHash);
writeOutput(result);
});
});
program
.command("setup")
.description("Configure provider, model, and agent")
.option("--provider <name>", "Provider name")
.option("--base-url <url>", "OpenAI-compatible API base URL")
.option("--api-key <key>", "API key")
.option("--model <name>", "Default model name")
.option("--agent <name>", "Default agent alias")
.action((opts: {
provider?: string;
baseUrl?: string;
apiKey?: string;
model?: string;
agent?: string;
}) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) {
const result = await cmdSetup({
provider: opts.provider,
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
model: opts.model,
agent: opts.agent ?? undefined,
storageRoot,
});
writeOutput(result);
} else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) {
await cmdSetupInteractive(storageRoot);
} else {
throw new Error(
"Non-interactive setup requires all of: --provider, --base-url, --api-key, --model",
);
}
});
});
const cas = program.command("cas").description("Content-addressable storage operations");
cas
.command("get")
.description("Read a CAS node (type + payload; use --timestamp to include timestamp)")
.argument("<hash>", "CAS hash (13 char)")
.option("--timestamp", "Include timestamp in output")
.action((hash: string, opts: { timestamp?: boolean }) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasGet(storageRoot, hash, opts));
});
});
cas
.command("put")
.description("Store a node, print its hash")
.argument("<type-hash>", "Type (schema) hash")
.argument("<data>", "JSON file path or inline JSON string")
.action((typeHash: string, data: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasPut(storageRoot, typeHash, data));
});
});
cas
.command("has")
.description("Check if a hash exists")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasHas(storageRoot, hash));
});
});
cas
.command("refs")
.description("List direct CAS references from a node")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasRefs(storageRoot, hash));
});
});
cas
.command("walk")
.description("Recursive traversal from a node")
.argument("<hash>", "CAS hash (13 char)")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasWalk(storageRoot, hash));
});
});
cas
.command("reindex")
.description("Rebuild type index from all CAS nodes")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasReindex(storageRoot));
});
});
const casSchema = cas.command("schema").description("CAS schema operations");
casSchema
.command("list")
.description("List all registered schemas")
.action(() => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasSchemaList(storageRoot));
});
});
casSchema
.command("get")
.description("Show a schema by its type hash")
.argument("<hash>", "Schema type hash")
.action((hash: string) => {
const storageRoot = resolveStorageRoot();
runAction(async () => {
writeOutput(await cmdCasSchemaGet(storageRoot, hash));
});
});
program.parseAsync(process.argv).catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
process.stderr.write(`${message}\n`);
process.exit(1);
});
-139
View File
@@ -1,139 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Hash, JSONSchema, Store } from "@uncaged/json-cas";
import { bootstrap, getSchema, refs, walk } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
// ---- Helpers ----
function openStore(storageRoot: string): Store {
return createFsStore(join(storageRoot, "cas"));
}
function readJsonArg(fileOrInline: string): unknown {
try {
return JSON.parse(fileOrInline);
} catch {
try {
return JSON.parse(readFileSync(fileOrInline, "utf-8"));
} catch (e) {
throw new Error(`Cannot parse JSON from "${fileOrInline}": ${e}`);
}
}
}
// ---- Commands (all return JSON-serializable data) ----
export async function cmdCasGet(
storageRoot: string,
hash: string,
opts: { timestamp?: boolean },
): Promise<unknown> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
if (opts.timestamp) {
return node;
}
const { timestamp: _, ...rest } = node as Record<string, unknown>;
return rest;
}
export async function cmdCasPut(
storageRoot: string,
typeHash: string,
data: string,
): Promise<{ hash: string }> {
const store = openStore(storageRoot);
const payload = readJsonArg(data);
const hash = await store.put(typeHash, payload);
return { hash };
}
export async function cmdCasHas(
storageRoot: string,
hash: string,
): Promise<{ exists: boolean }> {
const store = openStore(storageRoot);
return { exists: store.has(hash) };
}
export async function cmdCasRefs(
storageRoot: string,
hash: string,
): Promise<{ refs: string[] }> {
const store = openStore(storageRoot);
const node = store.get(hash);
if (node === null) {
throw new Error(`Node not found: ${hash}`);
}
return { refs: refs(store, node) };
}
export async function cmdCasWalk(
storageRoot: string,
hash: string,
): Promise<{ hashes: string[] }> {
const store = openStore(storageRoot);
const result: string[] = [];
walk(store, hash, (h) => {
result.push(h);
});
return { hashes: result };
}
export type SchemaListEntry = {
hash: string;
title: string;
};
export async function cmdCasSchemaList(
storageRoot: string,
): Promise<SchemaListEntry[]> {
const store = openStore(storageRoot);
const metaHash = await bootstrap(store);
const entries: SchemaListEntry[] = [];
// Include meta-schema itself
entries.push({ hash: metaHash, title: "(meta-schema)" });
for (const hash of store.listByType(metaHash)) {
if (hash === metaHash) continue;
const node = store.get(hash);
if (node !== null) {
const schema = node.payload as JSONSchema;
const title =
(schema.title as string | undefined) ??
(schema.description as string | undefined) ??
"(unnamed)";
entries.push({ hash, title });
}
}
return entries;
}
export async function cmdCasReindex(
storageRoot: string,
): Promise<{ status: string }> {
const indexDir = join(storageRoot, "cas", "_index");
const { rmSync } = await import("node:fs");
rmSync(indexDir, { recursive: true, force: true });
// Re-open store to trigger migration rebuild
openStore(storageRoot);
return { status: "reindexed" };
}
export async function cmdCasSchemaGet(
storageRoot: string,
hash: string,
): Promise<unknown> {
const store = openStore(storageRoot);
const schema = getSchema(store, hash);
if (schema === null) {
throw new Error(`Schema not found: ${hash}`);
}
return schema;
}
-332
View File
@@ -1,332 +0,0 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { stringify, parse } from "yaml";
/**
* Preset provider list — embedded to avoid runtime YAML loading dependency.
* Keep in sync with providers.yaml in cli-workflow.
*/
const PRESET_PROVIDERS = [
// International
{ name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" },
{ name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" },
{ name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" },
{ name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" },
// China
{ name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" },
{ name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" },
{ name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" },
{ name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" },
{ name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" },
{ name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" },
{ name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" },
// Local
{ name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" },
] as const;
type SetupArgs = {
provider: string;
baseUrl: string;
apiKey: string;
model: string;
agent?: string | undefined;
storageRoot: string;
};
function getConfigPath(root: string): string {
return join(root, "config.yaml");
}
function getEnvPath(root: string): string {
return join(root, ".env");
}
/**
* Load existing config.yaml or return empty structure.
*/
function loadExistingConfig(configPath: string): Record<string, unknown> {
try {
if (existsSync(configPath)) {
const raw = parse(readFileSync(configPath, "utf8")) as unknown;
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
}
} catch {
// ignore parse errors, start fresh
}
return {};
}
/**
* Load existing .env as key=value map.
*/
function loadEnvFile(envPath: string): Record<string, string> {
const env: Record<string, string> = {};
try {
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq > 0) {
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
}
}
} catch {
// ignore
}
return env;
}
function saveEnvFile(envPath: string, env: Record<string, string>): void {
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8");
}
function apiKeyEnvName(providerName: string): string {
return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
}
/**
* Merge setup args into config.yaml structure. Non-destructive — preserves existing entries.
*/
function mergeConfig(existing: Record<string, unknown>, args: SetupArgs): Record<string, unknown> {
const providers = (typeof existing.providers === "object" && existing.providers !== null
? { ...(existing.providers as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const envName = apiKeyEnvName(args.provider);
providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName };
const models = (typeof existing.models === "object" && existing.models !== null
? { ...(existing.models as Record<string, unknown>) }
: {}) as Record<string, unknown>;
models.default = { provider: args.provider, name: args.model };
const agents = (typeof existing.agents === "object" && existing.agents !== null
? { ...(existing.agents as Record<string, unknown>) }
: {}) as Record<string, unknown>;
const agentName = args.agent ?? "hermes";
if (Object.keys(agents).length === 0) {
agents.hermes = { command: "uwf-hermes", args: [] };
}
return {
...existing,
providers,
models,
agents,
defaultAgent: existing.defaultAgent ?? agentName,
defaultModel: existing.defaultModel ?? "default",
};
}
/**
* Non-interactive setup. All required args provided via CLI flags.
*/
export async function cmdSetup(args: SetupArgs): Promise<Record<string, unknown>> {
const { storageRoot } = args;
mkdirSync(storageRoot, { recursive: true });
const configPath = getConfigPath(storageRoot);
const envPath = getEnvPath(storageRoot);
const existing = loadExistingConfig(configPath);
const merged = mergeConfig(existing, args);
writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8");
// Write API key to .env
const envName = apiKeyEnvName(args.provider);
const envData = loadEnvFile(envPath);
envData[envName] = args.apiKey;
saveEnvFile(envPath, envData);
return {
configPath,
envPath,
provider: args.provider,
model: args.model,
defaultAgent: merged.defaultAgent,
};
}
/** Read a line with terminal echo disabled (for secrets). */
async function promptSecret(label: string): Promise<string> {
process.stdout.write(label);
return new Promise((resolve) => {
const rawWasSet = process.stdin.isRaw;
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
process.stdin.resume();
process.stdin.setEncoding("utf8");
let buf = "";
const onData = (chunk: string) => {
for (const c of chunk.toString()) {
if (c === "\n" || c === "\r" || c === "\u0004") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.stdin.pause();
process.stdin.removeListener("data", onData);
process.stdout.write("\n");
resolve(buf.trim());
return;
}
if (c === "\u007F" || c === "\b") {
if (buf.length > 0) {
buf = buf.slice(0, -1);
process.stdout.write("\b \b");
}
continue;
}
if (c === "\u0003") {
if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet);
process.exit(130);
}
buf += c;
process.stdout.write("*");
}
};
process.stdin.on("data", onData);
});
}
/** Fetch available models from an OpenAI-compatible /models endpoint. */
async function fetchModels(baseUrl: string, apiKey: string): Promise<string[]> {
try {
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return [];
const body = (await res.json()) as { data?: { id: string }[] };
if (!Array.isArray(body.data)) return [];
const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i;
return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort();
} catch {
return [];
}
}
/**
* Interactive setup — prompts user for provider, API key, model.
*/
export async function cmdSetupInteractive(storageRoot: string): Promise<Record<string, unknown>> {
const rl = createInterface({ input, output });
try {
console.log("Configure LLM provider for uwf workflow agents.\n");
// 1. Provider selection
const numWidth = String(PRESET_PROVIDERS.length + 1).length;
console.log("Select a provider:\n");
for (let i = 0; i < PRESET_PROVIDERS.length; i++) {
const p = PRESET_PROVIDERS[i];
if (!p) continue;
const num = String(i + 1).padStart(numWidth);
console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
}
const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth);
console.log(` ${customNum}) Custom (enter name and URL manually)\n`);
const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim();
const choiceNum = Number.parseInt(choice, 10);
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) {
throw new Error(`Invalid choice: ${choice}`);
}
let providerName: string;
let baseUrl: string;
if (choiceNum <= PRESET_PROVIDERS.length) {
const selected = PRESET_PROVIDERS[choiceNum - 1];
if (!selected) throw new Error("Invalid selection");
providerName = selected.name;
baseUrl = selected.baseUrl;
console.log(`\n → ${selected.label} (${selected.baseUrl})\n`);
} else {
providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim();
if (!providerName) throw new Error("Provider name required");
baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim();
if (!baseUrl) throw new Error("Base URL required");
}
// 2. API key
rl.close();
const apiKey = await promptSecret("API key: ");
if (!apiKey) throw new Error("API key required");
// 3. Model selection
const rl2 = createInterface({ input, output });
console.log("\nFetching available models...");
const models = await fetchModels(baseUrl, apiKey);
let model: string;
if (models.length > 0) {
console.log(`\nAvailable models (${models.length}):\n`);
const nw = String(models.length).length;
// Multi-column layout
const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0);
const colWidth = nw + 2 + maxLen + 4; // " N) name "
const termCols = process.stdout.columns || 100;
const cols = Math.max(1, Math.floor(termCols / colWidth));
const rows = Math.ceil(models.length / cols);
for (let r = 0; r < rows; r++) {
let line = "";
for (let c = 0; c < cols; c++) {
const idx = c * rows + r;
if (idx >= models.length) break;
const num = String(idx + 1).padStart(nw);
const name = (models[idx] ?? "").padEnd(maxLen);
line += ` ${num}) ${name} `;
}
console.log(line.trimEnd());
}
console.log(`\nChoose a number, or type a model name directly.`);
const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim();
if (!modelInput) throw new Error("Model required");
const modelNum = Number.parseInt(modelInput, 10);
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
model = models[modelNum - 1] ?? modelInput;
} else {
model = modelInput;
}
} else {
console.log("Could not fetch models. Enter model name manually.");
model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim();
if (!model) throw new Error("Model required");
}
rl2.close();
console.log(`${providerName}/${model}\n`);
await cmdSetup({
provider: providerName,
baseUrl,
apiKey,
model,
storageRoot,
});
console.log("Setup complete! Get started:\n");
console.log(" uwf workflow put <workflow.yaml> Register a workflow");
console.log(' uwf thread start <name> -p "..." Start a thread');
console.log(" uwf thread step <thread-id> Execute next step");
console.log("");
return null as unknown as Record<string, unknown>;
} finally {
rl.close();
}
}
-594
View File
@@ -1,594 +0,0 @@
import { execFileSync } from "node:child_process";
import { validate } from "@uncaged/json-cas";
import { getEnvPath, loadWorkflowConfig } from "@uncaged/uwf-agent-kit";
import { evaluate } from "@uncaged/uwf-moderator";
import type {
AgentAlias,
AgentConfig,
CasRef,
ModeratorContext,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
WorkflowConfig,
WorkflowPayload,
} from "@uncaged/uwf-protocol";
import { generateUlid } from "@uncaged/workflow-util";
import { config as loadDotenv } from "dotenv";
import {
appendThreadHistory,
createUwfStore,
findThreadInHistory,
loadThreadHistory,
loadThreadsIndex,
loadWorkflowRegistry,
resolveWorkflowHash,
saveThreadsIndex,
type ThreadHistoryLine,
type UwfStore,
} from "../store.js";
import { isCasRef } from "../validate.js";
const END_ROLE = "$END";
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
export type KillOutput = {
thread: ThreadId;
archived: boolean;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
async function resolveWorkflowCasRef(
uwf: UwfStore,
storageRoot: string,
workflowId: string,
): Promise<CasRef> {
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, workflowId);
if (!isCasRef(hash)) {
fail(`workflow not found: ${workflowId}`);
}
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
return hash;
}
function resolveWorkflowFromHead(uwf: UwfStore, head: CasRef): CasRef | null {
const node = uwf.store.get(head);
if (node === null) {
return null;
}
if (node.type === uwf.schemas.startNode) {
const payload = node.payload as StartNodePayload;
return payload.workflow;
}
const payload = node.payload as StepNodePayload;
if (typeof payload.start !== "string") {
return null;
}
const startNode = uwf.store.get(payload.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
return null;
}
return (startNode.payload as StartNodePayload).workflow;
}
export async function cmdThreadStart(
storageRoot: string,
workflowId: string,
prompt: string,
): Promise<StartOutput> {
const uwf = await createUwfStore(storageRoot);
const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId);
const threadId = generateUlid(Date.now()) as ThreadId;
const startPayload: StartNodePayload = {
workflow: workflowHash,
prompt,
};
const headHash = await uwf.store.put(uwf.schemas.startNode, startPayload);
const node = uwf.store.get(headHash);
if (node === null || !validate(uwf.store, node)) {
fail("stored StartNode failed schema validation");
}
const index = await loadThreadsIndex(storageRoot);
index[threadId] = headHash;
await saveThreadsIndex(storageRoot, index);
return { workflow: workflowHash, thread: threadId };
}
export async function cmdThreadShow(storageRoot: string, threadId: ThreadId): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, activeHead);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${activeHead}`);
}
return {
workflow,
thread: threadId,
head: activeHead,
done: false,
};
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return {
workflow: hist.workflow,
thread: threadId,
head: hist.head,
done: true,
};
}
fail(`thread not found: ${threadId}`);
}
async function threadListItemFromActive(
uwf: UwfStore,
threadId: ThreadId,
head: CasRef,
): Promise<ThreadListItem | null> {
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
return null;
}
return { thread: threadId, workflow, head };
}
export async function cmdThreadList(
storageRoot: string,
includeAll: boolean,
): Promise<ThreadListItem[]> {
const uwf = await createUwfStore(storageRoot);
const index = await loadThreadsIndex(storageRoot);
const items: ThreadListItem[] = [];
for (const [threadId, head] of Object.entries(index)) {
const item = await threadListItemFromActive(uwf, threadId as ThreadId, head);
if (item !== null) {
items.push(item);
}
}
if (!includeAll) {
return items;
}
const activeIds = new Set(items.map((i) => i.thread));
const history = await loadThreadHistory(storageRoot);
for (const entry of history) {
if (!activeIds.has(entry.thread)) {
items.push({
thread: entry.thread,
workflow: entry.workflow,
head: entry.head,
});
}
}
return items;
}
function walkChain(uwf: UwfStore, headHash: CasRef): ChainState {
const headNode = uwf.store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === uwf.schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== uwf.schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = uwf.store.get(newest.start);
if (startNode === null || startNode.type !== uwf.schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(uwf: UwfStore, outputRef: CasRef): unknown {
const node = uwf.store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
function buildModeratorContext(uwf: UwfStore, chain: ChainState): ModeratorContext {
const chronological = [...chain.stepsNewestFirst].reverse();
const steps: StepContext[] = chronological.map((step) => ({
role: step.role,
output: expandOutput(uwf, step.output),
detail: step.detail,
agent: step.agent,
}));
return { start: chain.start, steps };
}
function loadWorkflowPayload(uwf: UwfStore, workflowRef: CasRef): WorkflowPayload {
const node = uwf.store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${workflowRef} is not a Workflow`);
}
return node.payload as WorkflowPayload;
}
function parseAgentOverride(override: string): AgentConfig {
const parts = override
.trim()
.split(/\s+/)
.filter((p) => p.length > 0);
const command = parts[0];
if (command === undefined) {
fail("agent override must not be empty");
}
return { command, args: parts.slice(1) };
}
function resolveAgentConfig(
config: WorkflowConfig,
workflow: WorkflowPayload,
role: string,
agentOverride: string | null,
): AgentConfig {
if (agentOverride !== null) {
return parseAgentOverride(agentOverride);
}
let alias: AgentAlias = config.defaultAgent;
if (config.agentOverrides !== null) {
const roleOverrides = config.agentOverrides[workflow.name];
if (roleOverrides !== undefined && roleOverrides[role] !== undefined) {
alias = roleOverrides[role];
}
}
const agentConfig = config.agents[alias];
if (agentConfig === undefined) {
fail(`unknown agent alias in config: ${alias}`);
}
return agentConfig;
}
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) {
const err = e as NodeJS.ErrnoException & { stderr?: Buffer | string };
const stderr =
err.stderr === undefined
? ""
: typeof err.stderr === "string"
? err.stderr
: err.stderr.toString("utf8");
const detail = stderr.trim() !== "" ? `: ${stderr.trim()}` : "";
fail(`agent command failed (${agent.command})${detail}`);
}
const line = stdout.trim().split("\n").pop()?.trim() ?? "";
if (!isCasRef(line)) {
fail(`agent stdout is not a valid CAS hash: ${line || "(empty)"}`);
}
return line;
}
async function archiveThread(
storageRoot: string,
threadId: ThreadId,
workflow: CasRef,
head: CasRef,
): Promise<void> {
const index = await loadThreadsIndex(storageRoot);
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
await appendThreadHistory(storageRoot, {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
});
}
export async function cmdThreadStep(
storageRoot: string,
threadId: ThreadId,
agentOverride: string | null,
): Promise<StepOutput> {
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const workflowHash = chain.start.workflow;
const workflow = loadWorkflowPayload(uwf, workflowHash);
const context = buildModeratorContext(uwf, chain);
const nextResult = await evaluate(workflow, context);
if (!nextResult.ok) {
fail(nextResult.error.message);
}
if (nextResult.value === END_ROLE) {
await archiveThread(storageRoot, threadId, workflowHash, headHash);
return {
workflow: workflowHash,
thread: threadId,
head: headHash,
done: true,
};
}
const role = nextResult.value;
const config = await loadWorkflowConfig(storageRoot);
const agent = resolveAgentConfig(config, workflow, role, agentOverride);
loadDotenv({ path: getEnvPath(storageRoot) });
const newHead = spawnAgent(agent, threadId, role);
// Re-create store to pick up nodes written by the agent subprocess
const uwfAfter = await createUwfStore(storageRoot);
const newNode = uwfAfter.store.get(newHead);
if (newNode === null || newNode.type !== uwfAfter.schemas.stepNode) {
fail(`agent returned hash that is not a StepNode: ${newHead}`);
}
// Reload threads index to avoid overwriting changes made by the agent subprocess
const freshIndex = await loadThreadsIndex(storageRoot);
freshIndex[threadId] = newHead;
await saveThreadsIndex(storageRoot, freshIndex);
const chainAfter = walkChain(uwfAfter, newHead);
const contextAfter = buildModeratorContext(uwfAfter, chainAfter);
const afterResult = await evaluate(workflow, contextAfter);
if (!afterResult.ok) {
fail(afterResult.error.message);
}
const done = afterResult.value === END_ROLE;
if (done) {
await archiveThread(storageRoot, threadId, workflowHash, newHead);
}
return {
workflow: workflowHash,
thread: threadId,
head: newHead,
done,
};
}
async function resolveHeadHash(storageRoot: string, threadId: ThreadId): Promise<CasRef> {
const index = await loadThreadsIndex(storageRoot);
const activeHead = index[threadId];
if (activeHead !== undefined) {
return activeHead;
}
const hist = await findThreadInHistory(storageRoot, threadId);
if (hist !== null) {
return hist.head;
}
fail(`thread not found: ${threadId}`);
}
export async function cmdThreadSteps(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadStepsOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
const chain = walkChain(uwf, headHash);
const startNode = uwf.store.get(chain.startHash);
if (startNode === null) {
fail(`StartNode not found: ${chain.startHash}`);
}
const startEntry: StartEntry = {
hash: chain.startHash,
workflow: chain.start.workflow,
prompt: chain.start.prompt,
timestamp: startNode.timestamp,
};
const stepEntries: StepEntry[] = [];
// Walk again to get hashes for each step
let hash: CasRef | null = headHash;
const hashToNode = new Map<string, { payload: StepNodePayload; timestamp: number }>();
while (hash !== null) {
const node = uwf.store.get(hash);
if (node === null || node.type !== uwf.schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
hashToNode.set(hash, { payload, timestamp: node.timestamp });
hash = payload.prev;
}
// Build chronological list with hashes
// Walk from start's next to head
let cur: CasRef | null = chain.headIsStart ? null : headHash;
const ordered: { hash: CasRef; payload: StepNodePayload; timestamp: number }[] = [];
while (cur !== null) {
const entry = hashToNode.get(cur);
if (entry === undefined) break;
ordered.push({ hash: cur, ...entry });
cur = entry.payload.prev;
}
ordered.reverse();
for (const item of ordered) {
stepEntries.push({
hash: item.hash,
role: item.payload.role,
output: expandOutput(uwf, item.payload.output),
detail: item.payload.detail,
agent: item.payload.agent,
timestamp: item.timestamp,
});
}
return {
thread: threadId,
workflow: chain.start.workflow,
steps: [startEntry, ...stepEntries],
};
}
export async function cmdThreadFork(
storageRoot: string,
threadId: ThreadId,
stepHash: CasRef,
): Promise<ThreadForkOutput> {
const headHash = await resolveHeadHash(storageRoot, threadId);
const uwf = await createUwfStore(storageRoot);
// Verify stepHash belongs to this thread by walking the chain
let found = false;
let cur: CasRef | null = headHash;
while (cur !== null) {
if (cur === stepHash) {
found = true;
break;
}
const node = uwf.store.get(cur);
if (node === null) break;
if (node.type === uwf.schemas.startNode) {
// startHash check
if (cur === stepHash) {
found = true;
}
break;
}
const payload = node.payload as StepNodePayload;
cur = payload.prev;
}
if (!found) {
fail(`step ${stepHash} not found in thread ${threadId}`);
}
const newThreadId = generateUlid(Date.now()) as ThreadId;
const index = await loadThreadsIndex(storageRoot);
index[newThreadId] = stepHash;
await saveThreadsIndex(storageRoot, index);
return {
thread: newThreadId,
forkedFrom: {
thread: threadId,
step: stepHash,
},
};
}
export async function cmdThreadKill(storageRoot: string, threadId: ThreadId): Promise<KillOutput> {
const index = await loadThreadsIndex(storageRoot);
const head = index[threadId];
if (head === undefined) {
fail(`thread not active: ${threadId}`);
}
const uwf = await createUwfStore(storageRoot);
const workflow = resolveWorkflowFromHead(uwf, head);
if (workflow === null) {
fail(`failed to resolve workflow from head: ${head}`);
}
delete index[threadId];
await saveThreadsIndex(storageRoot, index);
const historyEntry: ThreadHistoryLine = {
thread: threadId,
workflow,
head,
completedAt: Date.now(),
};
await appendThreadHistory(storageRoot, historyEntry);
return { thread: threadId, archived: true };
}
-153
View File
@@ -1,153 +0,0 @@
import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type { CasRef, RoleDefinition, WorkflowPayload } from "@uncaged/uwf-protocol";
import { parse } from "yaml";
import {
createUwfStore,
findRegistryName,
loadWorkflowRegistry,
resolveWorkflowHash,
saveWorkflowRegistry,
type UwfStore,
} from "../store.js";
import { parseWorkflowPayload } from "../validate.js";
export type WorkflowListEntry = {
name: string;
hash: CasRef;
};
export type WorkflowPutOutput = {
name: string;
hash: CasRef;
};
export type WorkflowShowOutput = {
hash: CasRef;
name: string | null;
type: CasRef;
payload: WorkflowPayload;
timestamp: number;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function resolveOutputSchemaRef(
uwf: UwfStore,
roleName: string,
outputSchema: unknown,
): Promise<CasRef> {
if (!isJsonSchema(outputSchema)) {
fail(`role "${roleName}": outputSchema must be a JSON Schema object`);
}
const schema: JSONSchema = outputSchema.title === undefined
? { ...outputSchema, title: roleName }
: outputSchema;
return putSchema(uwf.store, schema);
}
async function materializeWorkflowPayload(
uwf: UwfStore,
raw: WorkflowPayload,
): Promise<WorkflowPayload> {
const roles: Record<string, RoleDefinition> = {};
for (const [roleName, role] of Object.entries(raw.roles)) {
const outputSchema = await resolveOutputSchemaRef(
uwf,
`${raw.name}.${roleName}`,
role.outputSchema,
);
roles[roleName] = {
description: role.description,
systemPrompt: role.systemPrompt,
outputSchema,
};
}
return {
name: raw.name,
description: raw.description,
roles,
conditions: raw.conditions,
graph: raw.graph,
};
}
export async function cmdWorkflowPut(
storageRoot: string,
filePath: string,
): Promise<WorkflowPutOutput> {
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch {
fail(`file not found: ${filePath}`);
}
let raw: unknown;
try {
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
const payload = parseWorkflowPayload(raw);
if (payload === null) {
fail("invalid workflow YAML: expected WorkflowPayload shape");
}
const uwf = await createUwfStore(storageRoot);
const materialized = await materializeWorkflowPayload(uwf, payload);
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
const node = uwf.store.get(hash);
if (node === null || !validate(uwf.store, node)) {
fail("stored workflow failed schema validation");
}
const registry = await loadWorkflowRegistry(storageRoot);
registry[materialized.name] = hash;
await saveWorkflowRegistry(storageRoot, registry);
return { name: materialized.name, hash };
}
export async function cmdWorkflowShow(
storageRoot: string,
id: string,
): Promise<WorkflowShowOutput> {
const uwf = await createUwfStore(storageRoot);
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, id);
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
const payload = node.payload as WorkflowPayload;
return {
hash,
name: findRegistryName(registry, hash),
type: node.type,
payload,
timestamp: node.timestamp,
};
}
export async function cmdWorkflowList(storageRoot: string): Promise<WorkflowListEntry[]> {
const registry = await loadWorkflowRegistry(storageRoot);
return Object.entries(registry).map(([name, hash]) => ({ name, hash }));
}
-12
View File
@@ -1,12 +0,0 @@
import { stringify } from "yaml";
export type OutputFormat = "json" | "yaml";
export function formatOutput(data: unknown, format: OutputFormat): string {
switch (format) {
case "json":
return JSON.stringify(data);
case "yaml":
return stringify(data).trimEnd();
}
}
-26
View File
@@ -1,26 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "@uncaged/uwf-protocol";
export type UwfSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
};
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every CLI invocation.
*/
export async function registerUwfSchemas(store: Store): Promise<UwfSchemaHashes> {
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
]);
return { workflow, startNode, stepNode };
}
-212
View File
@@ -1,212 +0,0 @@
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Hash, Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type { CasRef, ThreadId, ThreadListItem, ThreadsIndex } from "@uncaged/uwf-protocol";
import { parse, stringify } from "yaml";
import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js";
export type WorkflowRegistry = Record<string, CasRef>;
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/**
* Resolve storage root.
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
*/
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();
}
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
export function getRegistryPath(storageRoot: string): string {
return join(storageRoot, "workflows.yaml");
}
export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export function getHistoryPath(storageRoot: string): string {
return join(storageRoot, "history.jsonl");
}
export type ThreadHistoryLine = ThreadListItem & {
completedAt: number;
};
export type UwfStore = {
storageRoot: string;
store: Store;
schemas: UwfSchemaHashes;
};
export async function createUwfStore(storageRoot: string): Promise<UwfStore> {
const casDir = getCasDir(storageRoot);
await mkdir(casDir, { recursive: true });
const store = createFsStore(casDir);
const schemas = await registerUwfSchemas(store);
return { storageRoot, store, schemas };
}
export async function loadWorkflowRegistry(storageRoot: string): Promise<WorkflowRegistry> {
const path = getRegistryPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const registry: WorkflowRegistry = {};
for (const [name, hash] of Object.entries(raw as Record<string, unknown>)) {
if (typeof hash === "string") {
registry[name] = hash;
}
}
return registry;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
export async function saveWorkflowRegistry(
storageRoot: string,
registry: WorkflowRegistry,
): Promise<void> {
const path = getRegistryPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(registry, { indent: 2 });
await writeFile(path, text, "utf8");
}
export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): CasRef {
return registry[id] !== undefined ? registry[id] : id;
}
export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null {
for (const [name, h] of Object.entries(registry)) {
if (h === hash) {
return name;
}
}
return null;
}
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
const path = getThreadsPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw as Record<string, unknown>)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
export async function saveThreadsIndex(storageRoot: string, index: ThreadsIndex): Promise<void> {
const path = getThreadsPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const text = stringify(index, { indent: 2 });
await writeFile(path, text, "utf8");
}
export async function loadThreadHistory(storageRoot: string): Promise<ThreadHistoryLine[]> {
const path = getHistoryPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const lines: ThreadHistoryLine[] = [];
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (trimmed === "") {
continue;
}
let raw: unknown;
try {
raw = JSON.parse(trimmed) as unknown;
} catch {
continue;
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
continue;
}
const rec = raw as Record<string, unknown>;
const thread = rec.thread;
const workflow = rec.workflow;
const head = rec.head;
const completedAt = rec.completedAt;
if (
typeof thread === "string" &&
typeof workflow === "string" &&
typeof head === "string" &&
typeof completedAt === "number"
) {
lines.push({ thread: thread as ThreadId, workflow, head, completedAt });
}
}
return lines;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return [];
}
throw e;
}
}
export async function findThreadInHistory(
storageRoot: string,
threadId: ThreadId,
): Promise<ThreadHistoryLine | null> {
const history = await loadThreadHistory(storageRoot);
for (let i = history.length - 1; i >= 0; i--) {
const entry = history[i];
if (entry !== undefined && entry.thread === threadId) {
return entry;
}
}
return null;
}
export async function appendThreadHistory(
storageRoot: string,
entry: ThreadHistoryLine,
): Promise<void> {
const path = getHistoryPath(storageRoot);
await mkdir(storageRoot, { recursive: true });
const line = `${JSON.stringify(entry)}\n`;
await appendFile(path, line, "utf8");
}
-71
View File
@@ -1,71 +0,0 @@
import type { CasRef, WorkflowPayload } from "@uncaged/uwf-protocol";
const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/;
export function isCasRef(value: string): value is CasRef {
return CAS_REF_PATTERN.test(value);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRoleDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const outputSchema = value.outputSchema;
const schemaOk = isRecord(outputSchema) && typeof outputSchema.type === "string";
return (
typeof value.description === "string" && typeof value.systemPrompt === "string" && schemaOk
);
}
function isConditionDefinition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return typeof value.description === "string" && typeof value.expression === "string";
}
function isTransition(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
const condition = value.condition;
return typeof value.role === "string" && (condition === null || typeof condition === "string");
}
function isStringRecord(value: unknown, itemCheck: (item: unknown) => boolean): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(itemCheck);
}
function isGraph(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(
(transitions) => Array.isArray(transitions) && transitions.every((t) => isTransition(t)),
);
}
/** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */
export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null {
if (!isRecord(raw)) {
return null;
}
if (typeof raw.name !== "string" || typeof raw.description !== "string") {
return null;
}
if (
!isStringRecord(raw.roles, isRoleDefinition) ||
!isStringRecord(raw.conditions, isConditionDefinition) ||
!isGraph(raw.graph)
) {
return null;
}
return raw as WorkflowPayload;
}
-13
View File
@@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [
{ "path": "../uwf-protocol" },
{ "path": "../uwf-moderator" },
{ "path": "../uwf-agent-kit" }
]
}
@@ -1,121 +0,0 @@
import { describe, expect, test } from "bun:test";
import { createMemoryStore, refs, validate, walk } from "@uncaged/json-cas";
import {
computeDurationMs,
extractLastAssistantContent,
messageToTurnPayload,
parseSessionIdFromStdout,
storeHermesSessionDetail,
} from "../src/session-detail.js";
import type { HermesSessionJson, HermesSessionMessage } from "../src/types.js";
describe("parseSessionIdFromStdout", () => {
test("reads session_id from the last non-empty line", () => {
const stdout = "Done.\n\nsession_id: 20260518_223724_45ab80\n";
expect(parseSessionIdFromStdout(stdout)).toBe("20260518_223724_45ab80");
});
test("returns null when trailing line is not session_id", () => {
expect(parseSessionIdFromStdout("only assistant text\n")).toBeNull();
});
});
describe("messageToTurnPayload", () => {
test("maps assistant tool_calls to toolCalls", () => {
const msg: HermesSessionMessage = {
role: "assistant",
content: "",
reasoning: null,
tool_calls: [{ function: { name: "read_file", arguments: '{"path":"x"}' } }],
};
const turn = messageToTurnPayload(msg, 0);
expect(turn).toEqual({
index: 0,
role: "assistant",
content: "",
toolCalls: [{ name: "read_file", args: '{"path":"x"}' }],
reasoning: null,
});
});
test("skips user messages", () => {
const msg: HermesSessionMessage = {
role: "user",
content: "hi",
reasoning: null,
tool_calls: null,
};
expect(messageToTurnPayload(msg, 0)).toBeNull();
});
});
describe("extractLastAssistantContent", () => {
test("returns the last non-empty assistant content", () => {
const messages: HermesSessionMessage[] = [
{ role: "assistant", content: "first", reasoning: null, tool_calls: null },
{ role: "tool", content: "tool output", reasoning: null, tool_calls: null },
{ role: "assistant", content: "", reasoning: null, tool_calls: null },
{ role: "assistant", content: "final answer", reasoning: null, tool_calls: null },
];
expect(extractLastAssistantContent(messages)).toBe("final answer");
});
});
describe("computeDurationMs", () => {
test("computes elapsed time from session_start", () => {
const now = Date.parse("2026-05-18T13:32:59.028640Z");
const duration = computeDurationMs("2026-05-18T13:31:59.028640Z", now);
expect(duration).toBe(60_000);
});
});
describe("storeHermesSessionDetail", () => {
test("stores hermes-detail root with cas_ref turns walkable", async () => {
const session: HermesSessionJson = {
session_id: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
session_start: "2026-05-18T13:31:59.028640",
messages: [
{ role: "user", content: "task", reasoning: null, tool_calls: null },
{
role: "assistant",
content: "",
reasoning: "thinking",
tool_calls: [{ function: { name: "terminal", arguments: "{}" } }],
},
{ role: "tool", content: "ok", reasoning: null, tool_calls: null },
{ role: "assistant", content: "done", reasoning: null, tool_calls: null },
],
};
const store = createMemoryStore();
const now = Date.parse("2026-05-18T13:32:59.028640");
const { detailHash, output } = await storeHermesSessionDetail(store, session, now);
expect(output).toBe("done");
const detailNode = store.get(detailHash);
expect(detailNode).not.toBeNull();
if (detailNode === null) {
return;
}
expect(validate(store, detailNode)).toBe(true);
expect(detailNode.payload).toMatchObject({
sessionId: "20260518_133159_6a84e8",
model: "claude-opus-4.6",
duration: 60_000,
turnCount: 3,
});
const turnRefs = refs(store, detailNode);
expect(turnRefs).toHaveLength(3);
const visited: string[] = [];
walk(store, detailHash, (hash) => visited.push(hash));
expect(visited).toContain(detailHash);
for (const turnHash of turnRefs) {
expect(visited).toContain(turnHash);
}
});
});
-33
View File
@@ -1,33 +0,0 @@
{
"name": "@uncaged/uwf-agent-hermes",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"bin": {
"uwf-hermes": "./src/cli.ts"
},
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/uwf-agent-kit": "workspace:^"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env bun
import { createHermesAgent } from "./hermes.js";
const main = createHermesAgent();
void main();
-112
View File
@@ -1,112 +0,0 @@
import { spawn } from "node:child_process";
import { type AgentContext, type AgentRunResult, createAgent } from "@uncaged/uwf-agent-kit";
import {
loadHermesSession,
parseSessionIdFromStdout,
storeHermesRawOutput,
storeHermesSessionDetail,
} from "./session-detail.js";
const HERMES_COMMAND = "hermes";
const HERMES_MAX_TURNS = 90;
function buildHistorySummary(steps: AgentContext["steps"]): string {
if (steps.length === 0) {
return "";
}
const lines: string[] = ["## Previous Steps"];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (step === undefined) {
continue;
}
lines.push("");
lines.push(`### Step ${i + 1}: ${step.role}`);
lines.push(`Output: ${JSON.stringify(step.output)}`);
lines.push(`Agent: ${step.agent}`);
}
return lines.join("\n");
}
/** Assemble system prompt, task, and prior step outputs for Hermes. */
export function buildHermesPrompt(ctx: AgentContext): string {
const roleDef = ctx.workflow.roles[ctx.role];
const systemPrompt = roleDef?.systemPrompt ?? "";
const parts: string[] = [systemPrompt, "", "## Task", ctx.start.prompt];
const historyBlock = buildHistorySummary(ctx.steps);
if (historyBlock !== "") {
parts.push("", historyBlock);
}
return parts.join("\n");
}
function spawnHermesChat(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const args = [
"chat",
"-q",
prompt,
"--yolo",
"--max-turns",
String(HERMES_MAX_TURNS),
"--quiet",
];
const child = spawn(HERMES_COMMAND, args, {
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.on("error", (cause) => {
const message = cause instanceof Error ? cause.message : String(cause);
reject(new Error(`hermes spawn failed: ${message}`));
});
child.on("close", (code) => {
if (code === 0) {
resolve(stdout);
return;
}
const detail = stderr.trim() !== "" ? ` stderr=${stderr.trim()}` : "";
reject(new Error(`hermes exited with code ${code ?? "null"}${detail}`));
});
});
}
async function runHermes(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildHermesPrompt(ctx);
const rawOutput = await spawnHermesChat(fullPrompt);
const { store } = ctx;
const sessionId = parseSessionIdFromStdout(rawOutput);
if (sessionId !== null) {
const session = await loadHermesSession(sessionId);
if (session !== null) {
const { detailHash, output } = await storeHermesSessionDetail(store, session);
return { output, detailHash };
}
}
const detailHash = await storeHermesRawOutput(store, rawOutput);
return { output: rawOutput, detailHash };
}
/** Agent CLI factory: parses argv, runs Hermes, extracts output, writes StepNode. */
export function createHermesAgent(): () => Promise<void> {
return createAgent({
name: "hermes",
run: runHermes,
});
}
-1
View File
@@ -1 +0,0 @@
export { buildHermesPrompt, createHermesAgent } from "./hermes.js";
-57
View File
@@ -1,57 +0,0 @@
import type { JSONSchema } from "@uncaged/json-cas";
const HERMES_TOOL_CALL_SCHEMA: JSONSchema = {
type: "object",
required: ["name", "args"],
properties: {
name: { type: "string" },
args: { type: "string" },
},
additionalProperties: false,
};
export const HERMES_TURN_SCHEMA: JSONSchema = {
title: "hermes-turn",
type: "object",
required: ["index", "role", "content"],
properties: {
index: { type: "integer" },
role: { type: "string", enum: ["assistant", "tool"] },
content: { type: "string" },
toolCalls: {
anyOf: [{ type: "array", items: HERMES_TOOL_CALL_SCHEMA }, { type: "null" }],
},
reasoning: {
anyOf: [{ type: "string" }, { type: "null" }],
},
},
additionalProperties: false,
};
export const HERMES_DETAIL_SCHEMA: JSONSchema = {
title: "hermes-detail",
type: "object",
required: ["sessionId", "model", "duration", "turnCount", "turns"],
properties: {
sessionId: { type: "string" },
model: { type: "string" },
duration: { type: "integer" },
turnCount: { type: "integer" },
turns: {
type: "array",
items: { type: "string", format: "cas_ref" },
},
},
additionalProperties: false,
};
/** Fallback detail when Hermes session file is unavailable. */
export const HERMES_RAW_OUTPUT_SCHEMA: JSONSchema = {
title: "hermes-raw-output",
type: "object",
required: ["text"],
properties: {
text: { type: "string" },
},
additionalProperties: false,
};
@@ -1,228 +0,0 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { bootstrap, putSchema, type Store } from "@uncaged/json-cas";
import { HERMES_DETAIL_SCHEMA, HERMES_RAW_OUTPUT_SCHEMA, HERMES_TURN_SCHEMA } from "./schemas.js";
import type {
HermesDetailPayload,
HermesSessionJson,
HermesSessionMessage,
HermesToolCall,
HermesTurnPayload,
HermesTurnRole,
} from "./types.js";
const SESSION_ID_LINE = /^session_id:\s*(\S+)\s*$/i;
export function getHermesSessionsDir(): string {
return join(homedir(), ".hermes", "sessions");
}
export function getHermesSessionPath(sessionId: string): string {
return join(getHermesSessionsDir(), `session_${sessionId}.json`);
}
/** Parse `session_id: …` from the last non-empty line of Hermes stdout. */
export function parseSessionIdFromStdout(stdout: string): string | null {
const lines = stdout.split(/\r?\n/).map((line) => line.trim());
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i];
if (line === undefined || line === "") {
continue;
}
const match = SESSION_ID_LINE.exec(line);
if (match?.[1] !== undefined) {
return match[1];
}
break;
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseToolCalls(raw: unknown): HermesSessionMessage["tool_calls"] {
if (!Array.isArray(raw) || raw.length === 0) {
return null;
}
const calls: NonNullable<HermesSessionMessage["tool_calls"]> = [];
for (const entry of raw) {
if (!isRecord(entry)) {
continue;
}
const fn = entry.function;
if (!isRecord(fn)) {
continue;
}
const name = fn.name;
const args = fn.arguments;
if (typeof name !== "string" || typeof args !== "string") {
continue;
}
calls.push({ function: { name, arguments: args } });
}
return calls.length > 0 ? calls : null;
}
function normalizeMessage(raw: unknown): HermesSessionMessage | null {
if (!isRecord(raw)) {
return null;
}
const role = raw.role;
if (role !== "assistant" && role !== "tool" && role !== "user") {
return null;
}
const content = typeof raw.content === "string" ? raw.content : raw.content === null ? null : "";
const reasoning =
typeof raw.reasoning === "string"
? raw.reasoning
: raw.reasoning === null || raw.reasoning === undefined
? null
: null;
const tool_calls = parseToolCalls(raw.tool_calls);
return { role, content, reasoning, tool_calls };
}
function parseSessionJson(raw: unknown): HermesSessionJson | null {
if (!isRecord(raw)) {
return null;
}
const session_id = raw.session_id;
const model = raw.model;
const session_start = raw.session_start;
const messagesRaw = raw.messages;
if (
typeof session_id !== "string" ||
typeof model !== "string" ||
typeof session_start !== "string" ||
!Array.isArray(messagesRaw)
) {
return null;
}
const messages: HermesSessionMessage[] = [];
for (const entry of messagesRaw) {
const msg = normalizeMessage(entry);
if (msg !== null) {
messages.push(msg);
}
}
return { session_id, model, session_start, messages };
}
export async function loadHermesSession(sessionId: string): Promise<HermesSessionJson | null> {
const path = getHermesSessionPath(sessionId);
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
return parseSessionJson(raw);
} catch {
return null;
}
}
export function computeDurationMs(sessionStart: string, nowMs: number = Date.now()): number {
const startMs = Date.parse(sessionStart);
if (Number.isNaN(startMs)) {
return 0;
}
return Math.max(0, nowMs - startMs);
}
function mapSessionToolCalls(
toolCalls: HermesSessionMessage["tool_calls"],
): HermesToolCall[] | null {
if (toolCalls === null || toolCalls.length === 0) {
return null;
}
return toolCalls.map((call) => ({
name: call.function.name,
args: call.function.arguments,
}));
}
export function messageToTurnPayload(
message: HermesSessionMessage,
index: number,
): HermesTurnPayload | null {
if (message.role !== "assistant" && message.role !== "tool") {
return null;
}
const role = message.role as HermesTurnRole;
return {
index,
role,
content: message.content ?? "",
toolCalls: mapSessionToolCalls(message.tool_calls),
reasoning: message.reasoning,
};
}
/** Last assistant message with non-empty text content (walks backward). */
export function extractLastAssistantContent(messages: HermesSessionMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg === undefined) {
continue;
}
if (msg.role === "assistant" && msg.content !== null && msg.content.trim() !== "") {
return msg.content;
}
}
return "";
}
type HermesSchemaHashes = {
turn: string;
detail: string;
rawOutput: string;
};
async function registerHermesSchemas(store: Store): Promise<HermesSchemaHashes> {
await bootstrap(store);
const [turn, detail, rawOutput] = await Promise.all([
putSchema(store, HERMES_TURN_SCHEMA),
putSchema(store, HERMES_DETAIL_SCHEMA),
putSchema(store, HERMES_RAW_OUTPUT_SCHEMA),
]);
return { turn, detail, rawOutput };
}
export async function storeHermesSessionDetail(
store: Store,
session: HermesSessionJson,
nowMs: number = Date.now(),
): Promise<{ detailHash: string; output: string }> {
const schemas = await registerHermesSchemas(store);
const turnHashes: string[] = [];
let turnIndex = 0;
for (const message of session.messages) {
const turn = messageToTurnPayload(message, turnIndex);
if (turn === null) {
continue;
}
const hash = await store.put(schemas.turn, turn);
turnHashes.push(hash);
turnIndex += 1;
}
const detail: HermesDetailPayload = {
sessionId: session.session_id,
model: session.model,
duration: computeDurationMs(session.session_start, nowMs),
turnCount: turnHashes.length,
turns: turnHashes,
};
const detailHash = await store.put(schemas.detail, detail);
const output = extractLastAssistantContent(session.messages);
return { detailHash, output };
}
export async function storeHermesRawOutput(store: Store, rawOutput: string): Promise<string> {
const schemas = await registerHermesSchemas(store);
return store.put(schemas.rawOutput, { text: rawOutput });
}
-43
View File
@@ -1,43 +0,0 @@
export type HermesTurnRole = "assistant" | "tool";
export type HermesToolCall = {
name: string;
args: string;
};
export type HermesTurnPayload = {
index: number;
role: HermesTurnRole;
content: string;
toolCalls: HermesToolCall[] | null;
reasoning: string | null;
};
export type HermesDetailPayload = {
sessionId: string;
model: string;
duration: number;
turnCount: number;
turns: string[];
};
export type HermesSessionToolCall = {
function: {
name: string;
arguments: string;
};
};
export type HermesSessionMessage = {
role: string;
content: string | null;
tool_calls: HermesSessionToolCall[] | null;
reasoning: string | null;
};
export type HermesSessionJson = {
session_id: string;
model: string;
session_start: string;
messages: HermesSessionMessage[];
};
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-agent-kit" }]
}
@@ -1,42 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { WorkflowConfig } from "@uncaged/uwf-protocol";
import { resolveExtractModelAlias } from "../src/extract.js";
function baseConfig(overrides: Partial<WorkflowConfig> = {}): WorkflowConfig {
return {
providers: {},
models: {
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
"gpt4o-mini": { provider: "openai", name: "gpt-4o-mini" },
},
agents: {},
defaultAgent: "hermes",
agentOverrides: null,
defaultModel: "sonnet",
modelOverrides: null,
...overrides,
};
}
describe("resolveExtractModelAlias", () => {
test("uses modelOverrides.extract when set", () => {
const config = baseConfig({
modelOverrides: { extract: "gpt4o-mini" },
});
expect(resolveExtractModelAlias(config)).toBe("gpt4o-mini");
});
test("falls back to models.extract alias when present", () => {
const config = baseConfig({
models: {
extract: { provider: "openai", name: "gpt-4o-mini" },
sonnet: { provider: "openrouter", name: "anthropic/claude-sonnet-4" },
},
});
expect(resolveExtractModelAlias(config)).toBe("extract");
});
test("falls back to defaultModel", () => {
expect(resolveExtractModelAlias(baseConfig())).toBe("sonnet");
});
});
-33
View File
@@ -1,33 +0,0 @@
{
"name": "@uncaged/uwf-agent-kit",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"test": "bun test"
},
"dependencies": {
"@uncaged/json-cas": "^0.3.0",
"@uncaged/json-cas-fs": "^0.3.0",
"@uncaged/uwf-protocol": "workspace:^",
"dotenv": "^16.6.1",
"yaml": "^2.8.4"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
-201
View File
@@ -1,201 +0,0 @@
import type { Store } from "@uncaged/json-cas";
import type {
CasRef,
StartNodePayload,
StepContext,
StepNodePayload,
ThreadId,
} from "@uncaged/uwf-protocol";
import { createAgentStore, loadThreadsIndex, resolveStorageRoot } from "./storage.js";
import type { AgentStore } from "./storage.js";
import type { AgentContext } from "./types.js";
type ChainState = {
startHash: CasRef;
start: StartNodePayload;
stepsNewestFirst: StepNodePayload[];
headIsStart: boolean;
};
function fail(message: string): never {
throw new Error(message);
}
function walkChain(
store: Store,
schemas: AgentStore["schemas"],
headHash: CasRef,
): ChainState {
const headNode = store.get(headHash);
if (headNode === null) {
fail(`CAS node not found: ${headHash}`);
}
if (headNode.type === schemas.startNode) {
return {
startHash: headHash,
start: headNode.payload as StartNodePayload,
stepsNewestFirst: [],
headIsStart: true,
};
}
if (headNode.type !== schemas.stepNode) {
fail(`head ${headHash} is not a StartNode or StepNode`);
}
const stepsNewestFirst: StepNodePayload[] = [];
let hash: CasRef | null = headHash;
while (hash !== null) {
const node = store.get(hash);
if (node === null) {
fail(`CAS node not found while walking chain: ${hash}`);
}
if (node.type !== schemas.stepNode) {
break;
}
const payload = node.payload as StepNodePayload;
stepsNewestFirst.push(payload);
hash = payload.prev;
}
const newest = stepsNewestFirst[0];
if (newest === undefined) {
fail(`empty step chain at head ${headHash}`);
}
const startNode = store.get(newest.start);
if (startNode === null || startNode.type !== schemas.startNode) {
fail(`StartNode not found: ${newest.start}`);
}
return {
startHash: newest.start,
start: startNode.payload as StartNodePayload,
stepsNewestFirst,
headIsStart: false,
};
}
function expandOutput(
store: Store,
outputRef: CasRef,
): unknown {
const node = store.get(outputRef);
if (node === null) {
return {};
}
return node.payload;
}
async function buildHistory(
store: Store,
stepsNewestFirst: StepNodePayload[],
): Promise<StepContext[]> {
const chronological = [...stepsNewestFirst].reverse();
const history: StepContext[] = [];
for (const step of chronological) {
history.push({
role: step.role,
output: expandOutput(store, step.output),
detail: step.detail,
agent: step.agent,
});
}
return history;
}
async function loadWorkflow(
store: Store,
schemas: AgentStore["schemas"],
workflowRef: CasRef,
) {
const node = store.get(workflowRef);
if (node === null) {
fail(`workflow CAS node not found: ${workflowRef}`);
}
if (node.type !== schemas.workflow) {
fail(`node ${workflowRef} is not a Workflow`);
}
return node.payload as AgentContext["workflow"];
}
/**
* Build agent execution context from thread head in threads.yaml.
* Walks the CAS chain from head to StartNode and expands step outputs.
*/
export async function buildContext(threadId: ThreadId, role: string): Promise<AgentContext> {
const storageRoot = resolveStorageRoot();
const agentStore = await createAgentStore(storageRoot);
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
const chain = walkChain(store, schemas, headHash);
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
const roleDef = workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
return {
threadId,
role,
start: chain.start,
steps,
workflow,
store,
};
}
export type BuildContextMeta = {
storageRoot: string;
store: Store;
schemas: AgentStore["schemas"];
headHash: CasRef;
chain: ChainState;
};
/**
* Same as {@link buildContext} but also returns chain metadata for writing the next StepNode.
*/
export async function buildContextWithMeta(
threadId: ThreadId,
role: string,
): Promise<AgentContext & { meta: BuildContextMeta }> {
const storageRoot = resolveStorageRoot();
const agentStore = await createAgentStore(storageRoot);
const { store, schemas } = agentStore;
const index = await loadThreadsIndex(storageRoot);
const headHash = index[threadId];
if (headHash === undefined) {
fail(`thread not found in threads.yaml: ${threadId}`);
}
const chain = walkChain(store, schemas, headHash);
const workflow = await loadWorkflow(store, schemas, chain.start.workflow);
const roleDef = workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role "${role}" in workflow "${workflow.name}"`);
}
const steps = await buildHistory(store, chain.stepsNewestFirst);
return {
threadId,
role,
start: chain.start,
steps,
workflow,
store,
meta: { storageRoot, store, schemas, headHash, chain },
};
}
-181
View File
@@ -1,181 +0,0 @@
import { getSchema, validate } from "@uncaged/json-cas";
import type { CasRef, ModelAlias, WorkflowConfig } from "@uncaged/uwf-protocol";
import { config as loadDotenv } from "dotenv";
import { createAgentStore, getEnvPath, resolveStorageRoot } from "./storage.js";
export type ResolvedLlmProvider = {
baseUrl: string;
apiKey: string;
model: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Resolve model alias for extract: modelOverrides.extract → models.extract → defaultModel. */
export function resolveExtractModelAlias(config: WorkflowConfig): ModelAlias {
const fromOverride = config.modelOverrides?.extract ?? null;
if (fromOverride !== null) {
return fromOverride;
}
if (config.models.extract !== undefined) {
return "extract";
}
if (config.models.default !== undefined) {
return "default";
}
return config.defaultModel;
}
export function resolveModel(config: WorkflowConfig, alias: ModelAlias): ResolvedLlmProvider {
const modelEntry = config.models[alias];
if (modelEntry === undefined) {
throw new Error(`unknown model alias: ${alias}`);
}
const providerEntry = config.providers[modelEntry.provider];
if (providerEntry === undefined) {
throw new Error(`unknown provider "${modelEntry.provider}" for model "${alias}"`);
}
const apiKey = process.env[providerEntry.apiKeyEnv];
if (apiKey === undefined || apiKey === "") {
throw new Error(`missing API key env var: ${providerEntry.apiKeyEnv}`);
}
return {
baseUrl: providerEntry.baseUrl,
apiKey,
model: modelEntry.name,
};
}
function chatUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function extractJsonFromAssistantText(text: string): unknown {
const trimmed = text.trim();
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
const candidate = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
return JSON.parse(candidate) as unknown;
}
function parseAssistantText(parsed: unknown): string {
if (!isRecord(parsed)) {
throw new Error("LLM response is not an object");
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
throw new Error("LLM response has no choices");
}
const c0 = choices[0];
if (!isRecord(c0)) {
throw new Error("LLM choice is not an object");
}
const messageObj = c0.message;
if (!isRecord(messageObj)) {
throw new Error("LLM message is not an object");
}
const content = messageObj.content;
if (typeof content !== "string") {
throw new Error("LLM message has no text content");
}
return content;
}
async function chatCompletionText(
provider: ResolvedLlmProvider,
messages: Array<{ role: "system" | "user"; content: string }>,
): Promise<string> {
let response: Response;
try {
response = await fetch(chatUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages,
response_format: { type: "json_object" },
}),
});
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM network error: ${message}`);
}
const responseText = await response.text();
if (!response.ok) {
throw new Error(`LLM HTTP ${response.status}: ${responseText.slice(0, 2000)}`);
}
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`LLM invalid JSON response: ${message}`);
}
return parseAssistantText(parsed);
}
export type ExtractResult = {
value: unknown;
hash: CasRef;
};
/**
* Call an OpenAI-compatible LLM to extract structured output matching outputSchema.
* Loads config.yaml and .env from the workflow storage root.
*/
export async function extract(
rawOutput: string,
outputSchema: CasRef,
config: WorkflowConfig,
): Promise<ExtractResult> {
const storageRoot = resolveStorageRoot();
loadDotenv({ path: getEnvPath(storageRoot) });
const { store } = await createAgentStore(storageRoot);
const schema = getSchema(store, outputSchema);
if (schema === null) {
throw new Error(`output schema not found in CAS: ${outputSchema}`);
}
const modelAlias = resolveExtractModelAlias(config);
const provider = resolveModel(config, modelAlias);
const schemaText = JSON.stringify(schema, null, 2);
const assistantText = await chatCompletionText(provider, [
{
role: "system",
content:
"Extract structured data from the agent output. Reply with a single JSON object only, no markdown or prose. The JSON must validate against this JSON Schema:\n" +
schemaText,
},
{
role: "user",
content: rawOutput,
},
]);
let structured: unknown;
try {
structured = extractJsonFromAssistantText(assistantText);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
throw new Error(`failed to parse extracted JSON: ${message}`);
}
const outputHash = await store.put(outputSchema, structured);
const node = store.get(outputHash);
if (node === null || !validate(store, node)) {
throw new Error("extracted output failed JSON Schema validation");
}
return { value: structured, hash: outputHash };
}
-11
View File
@@ -1,11 +0,0 @@
export type { BuildContextMeta } from "./context.js";
export { buildContext, buildContextWithMeta } from "./context.js";
export type { ExtractResult, ResolvedLlmProvider } from "./extract.js";
export {
extract,
resolveExtractModelAlias,
resolveModel,
} from "./extract.js";
export { createAgent } from "./run.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig } from "./storage.js";
export type { AgentContext, AgentOptions, AgentRunFn, AgentRunResult } from "./types.js";
-134
View File
@@ -1,134 +0,0 @@
import { validate } from "@uncaged/json-cas";
import type { CasRef, StepNodePayload, ThreadId } from "@uncaged/uwf-protocol";
import { config as loadDotenv } from "dotenv";
import { buildContextWithMeta } from "./context.js";
import { extract } from "./extract.js";
import type { AgentStore } from "./storage.js";
import { getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
import type { AgentContext, AgentOptions, AgentRunResult } from "./types.js";
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function agentLabel(name: string): string {
if (name.startsWith("uwf-")) {
return name;
}
return `uwf-${name}`;
}
function parseArgv(argv: string[]): { threadId: ThreadId; role: string } {
const threadId = argv[2];
const role = argv[3];
if (threadId === undefined || threadId === "") {
fail("usage: <agent-cli> <thread-id> <role>");
}
if (role === undefined || role === "") {
fail("usage: <agent-cli> <thread-id> <role>");
}
return { threadId: threadId as ThreadId, role };
}
function runWithMessage<T>(label: string, fn: () => Promise<T>): Promise<T> {
return fn().catch((e: unknown) => {
const message = e instanceof Error ? e.message : String(e);
fail(`${label}: ${message}`);
});
}
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,
};
const hash = await options.store.put(options.schemas.stepNode, payload);
const node = options.store.get(hash);
if (node === null || !validate(options.store, node)) {
fail("stored StepNode failed schema validation");
}
return hash;
}
async function runAgent(options: AgentOptions, ctx: AgentContext): Promise<AgentRunResult> {
return runWithMessage("agent run failed", () => options.run(ctx));
}
async function extractOutput(
rawOutput: string,
outputSchema: CasRef,
storageRoot: string,
): Promise<CasRef> {
const config = await runWithMessage("failed to load config", () =>
loadWorkflowConfig(storageRoot),
);
const extracted = await runWithMessage("extract failed", () =>
extract(rawOutput, outputSchema, config),
);
return extracted.hash;
}
async function persistStep(options: {
ctx: Awaited<ReturnType<typeof buildContextWithMeta>>;
outputHash: CasRef;
detailHash: CasRef;
agentName: string;
}): Promise<CasRef> {
const { store, schemas, chain, headHash } = options.ctx.meta;
return writeStepNode({
store,
schemas,
startHash: chain.startHash,
prevHash: chain.headIsStart ? null : headHash,
role: options.ctx.role,
outputHash: options.outputHash,
detailHash: options.detailHash,
agentName: options.agentName,
});
}
/**
* Create an agent CLI entrypoint.
* Parses argv (`<thread-id> <role>`), runs the agent, extracts structured output,
* writes StepNode to CAS, and prints the new node hash to stdout.
*/
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 runWithMessage("context", () => buildContextWithMeta(threadId, role));
const roleDef = ctx.workflow.roles[role];
if (roleDef === undefined) {
fail(`unknown role: ${role}`);
}
const agentResult = await runAgent(options, ctx);
const outputHash = await extractOutput(agentResult.output, roleDef.outputSchema, storageRoot);
const stepHash = await persistStep({
ctx,
outputHash,
detailHash: agentResult.detailHash,
agentName: agentLabel(options.name),
});
process.stdout.write(`${stepHash}\n`);
};
}
-22
View File
@@ -1,22 +0,0 @@
import type { Hash, Store } from "@uncaged/json-cas";
import { putSchema } from "@uncaged/json-cas";
import { START_NODE_SCHEMA, STEP_NODE_SCHEMA, WORKFLOW_SCHEMA } from "@uncaged/uwf-protocol";
export type UwfAgentSchemaHashes = {
workflow: Hash;
startNode: Hash;
stepNode: Hash;
};
/**
* Register Workflow, StartNode, and StepNode JSON Schemas in the CAS store.
* Idempotent: safe to call on every agent invocation.
*/
export async function registerAgentSchemas(store: Store): Promise<UwfAgentSchemaHashes> {
const [workflow, startNode, stepNode] = await Promise.all([
putSchema(store, WORKFLOW_SCHEMA),
putSchema(store, START_NODE_SCHEMA),
putSchema(store, STEP_NODE_SCHEMA),
]);
return { workflow, startNode, stepNode };
}
-227
View File
@@ -1,227 +0,0 @@
import { readFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { Store } from "@uncaged/json-cas";
import { createFsStore } from "@uncaged/json-cas-fs";
import type {
AgentAlias,
AgentConfig,
ModelAlias,
ModelConfig,
ProviderAlias,
ProviderConfig,
Scenario,
ThreadId,
ThreadsIndex,
WorkflowConfig,
WorkflowName,
} from "@uncaged/uwf-protocol";
import { parse } from "yaml";
import { registerAgentSchemas } from "./schemas.js";
/** Default filesystem root for uwf data (`~/.uncaged/workflow`). */
export function getDefaultStorageRoot(): string {
return join(homedir(), ".uncaged", "workflow");
}
/**
* Resolve storage root.
* Priority: `UNCAGED_WORKFLOW_STORAGE_ROOT` → `WORKFLOW_STORAGE_ROOT` → default.
*/
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();
}
export function getCasDir(storageRoot: string): string {
return join(storageRoot, "cas");
}
export function getConfigPath(storageRoot: string): string {
return join(storageRoot, "config.yaml");
}
export function getEnvPath(storageRoot: string): string {
return join(storageRoot, ".env");
}
export function getThreadsPath(storageRoot: string): string {
return join(storageRoot, "threads.yaml");
}
export type AgentStore = {
storageRoot: string;
store: Store;
schemas: Awaited<ReturnType<typeof registerAgentSchemas>>;
};
export async function createAgentStore(storageRoot: string): Promise<AgentStore> {
const store = createFsStore(getCasDir(storageRoot));
const schemas = await registerAgentSchemas(store);
return { storageRoot, store, schemas };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeProviders(raw: unknown): Record<ProviderAlias, ProviderConfig> {
if (!isRecord(raw)) {
throw new Error("config.providers must be a mapping");
}
const providers: Record<ProviderAlias, ProviderConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.providers.${name} must be a mapping`);
}
const baseUrl = entry.baseUrl;
const apiKeyEnv = entry.apiKeyEnv;
if (typeof baseUrl !== "string" || typeof apiKeyEnv !== "string") {
throw new Error(`config.providers.${name} requires baseUrl and apiKeyEnv`);
}
providers[name] = { baseUrl, apiKeyEnv };
}
return providers;
}
function normalizeModels(raw: unknown): Record<ModelAlias, ModelConfig> {
if (!isRecord(raw)) {
throw new Error("config.models must be a mapping");
}
const models: Record<ModelAlias, ModelConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.models.${name} must be a mapping`);
}
const provider = entry.provider;
const modelName = entry.name;
if (typeof provider !== "string" || typeof modelName !== "string") {
throw new Error(`config.models.${name} requires provider and name`);
}
models[name] = { provider, name: modelName };
}
return models;
}
function normalizeAgents(raw: unknown): Record<AgentAlias, AgentConfig> {
if (!isRecord(raw)) {
throw new Error("config.agents must be a mapping");
}
const agents: Record<AgentAlias, AgentConfig> = {};
for (const [name, entry] of Object.entries(raw)) {
if (!isRecord(entry)) {
throw new Error(`config.agents.${name} must be a mapping`);
}
const command = entry.command;
const argsRaw = entry.args;
if (typeof command !== "string") {
throw new Error(`config.agents.${name} requires command`);
}
const args = Array.isArray(argsRaw)
? argsRaw.filter((a): a is string => typeof a === "string")
: [];
agents[name] = { command, args };
}
return agents;
}
function normalizeModelOverrides(raw: unknown): Record<Scenario, ModelAlias> | null {
if (raw === undefined || raw === null) {
return null;
}
if (!isRecord(raw)) {
throw new Error("config.modelOverrides must be a mapping or null");
}
const overrides: Record<Scenario, ModelAlias> = {};
for (const [scene, alias] of Object.entries(raw)) {
if (typeof alias === "string") {
overrides[scene] = alias;
}
}
return overrides;
}
function normalizeAgentOverrides(
raw: unknown,
): Record<WorkflowName, Record<string, AgentAlias>> | null {
if (raw === undefined || raw === null) {
return null;
}
if (!isRecord(raw)) {
throw new Error("config.agentOverrides must be a mapping or null");
}
const overrides: Record<WorkflowName, Record<string, AgentAlias>> = {};
for (const [workflowName, rolesRaw] of Object.entries(raw)) {
if (!isRecord(rolesRaw)) {
continue;
}
const roles: Record<string, AgentAlias> = {};
for (const [roleName, alias] of Object.entries(rolesRaw)) {
if (typeof alias === "string") {
roles[roleName] = alias;
}
}
overrides[workflowName] = roles;
}
return overrides;
}
export function normalizeWorkflowConfig(raw: unknown): WorkflowConfig {
if (!isRecord(raw)) {
throw new Error("config.yaml root must be a mapping");
}
const defaultAgent = raw.defaultAgent;
const defaultModel = raw.defaultModel;
if (typeof defaultAgent !== "string" || typeof defaultModel !== "string") {
throw new Error("config requires defaultAgent and defaultModel");
}
return {
providers: normalizeProviders(raw.providers),
models: normalizeModels(raw.models),
agents: normalizeAgents(raw.agents),
defaultAgent,
agentOverrides: normalizeAgentOverrides(raw.agentOverrides),
defaultModel,
modelOverrides: normalizeModelOverrides(raw.modelOverrides),
};
}
export async function loadWorkflowConfig(storageRoot: string): Promise<WorkflowConfig> {
const path = getConfigPath(storageRoot);
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
return normalizeWorkflowConfig(raw);
}
export async function loadThreadsIndex(storageRoot: string): Promise<ThreadsIndex> {
const path = getThreadsPath(storageRoot);
try {
const text = await readFile(path, "utf8");
const raw = parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const index: ThreadsIndex = {};
for (const [threadId, head] of Object.entries(raw)) {
if (typeof head === "string") {
index[threadId as ThreadId] = head;
}
}
return index;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
-21
View File
@@ -1,21 +0,0 @@
import type { Store } from "@uncaged/json-cas";
import type { ModeratorContext, ThreadId, WorkflowPayload } from "@uncaged/uwf-protocol";
export type AgentContext = ModeratorContext & {
threadId: ThreadId;
role: string;
store: Store;
workflow: WorkflowPayload;
};
export type AgentRunResult = {
output: string;
detailHash: string;
};
export type AgentRunFn = (ctx: AgentContext) => Promise<AgentRunResult>;
export type AgentOptions = {
name: string;
run: AgentRunFn;
};
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-protocol" }]
}
@@ -1,120 +0,0 @@
import { describe, expect, test } from "bun:test";
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
import { evaluate } from "../src/evaluate.js";
const solveIssueWorkflow: WorkflowPayload = {
name: "solve-issue",
description: "End-to-end issue resolution",
roles: {
planner: {
description: "Creates implementation plan",
systemPrompt: "You are a planning agent...",
outputSchema: "5GWKR8TN1V3JA",
},
developer: {
description: "Implements code changes",
systemPrompt: "You are a developer agent...",
outputSchema: "8CNWT4KR6D1HV",
},
reviewer: {
description: "Reviews code changes",
systemPrompt: "You are a code reviewer...",
outputSchema: "1VPBG9SM5E7WK",
},
},
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 }],
planner: [
{ role: "developer", condition: "needsClarification" },
{ role: "$END", condition: null },
],
developer: [{ role: "reviewer", condition: null }],
reviewer: [
{ role: "developer", condition: "notApproved" },
{ role: "$END", condition: null },
],
},
};
function makeContext(steps: ModeratorContext["steps"]): ModeratorContext {
return {
start: {
workflow: "4KNM2PXR3B1QW",
prompt: "Fix the login bug",
},
steps,
};
}
describe("evaluate", () => {
test("$START → first role (fallback)", async () => {
const result = await evaluate(solveIssueWorkflow, makeContext([]));
expect(result).toEqual({ ok: true, value: "planner" });
});
test("condition match (notApproved → developer)", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: false },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
test("fallback when condition does not match → $END", async () => {
const context = makeContext([
{
role: "reviewer",
output: { approved: true },
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "$END" });
});
test("missing role in graph → error", async () => {
const context = makeContext([
{
role: "unknown-role",
output: {},
detail: "2MXBG6PN4A8JR",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('no transitions defined for role "unknown-role"');
}
});
test("output expansion in context works with JSONata", async () => {
const context = makeContext([
{
role: "planner",
output: { needsClarification: true },
detail: "7BQST3VW9F2MA",
agent: "uwf-hermes",
},
]);
const result = await evaluate(solveIssueWorkflow, context);
expect(result).toEqual({ ok: true, value: "developer" });
});
});
-82
View File
@@ -1,82 +0,0 @@
import type { ModeratorContext, WorkflowPayload } from "@uncaged/uwf-protocol";
import jsonata from "jsonata";
import type { Result } from "./types.js";
const START_ROLE = "$START";
function isTruthy(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0 && !Number.isNaN(value);
}
if (typeof value === "string") {
return value.length > 0;
}
return true;
}
async function evaluateJsonata(expression: string, context: ModeratorContext): Promise<Result<unknown, Error>> {
try {
const result = await jsonata(expression).evaluate(context);
return { ok: true, value: result };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
function currentRole(context: ModeratorContext): string {
if (context.steps.length === 0) {
return START_ROLE;
}
return context.steps[context.steps.length - 1].role;
}
export async function evaluate(
workflow: WorkflowPayload,
context: ModeratorContext,
): Promise<Result<string, Error>> {
const role = currentRole(context);
const transitions = workflow.graph[role];
if (transitions === undefined) {
return {
ok: false,
error: new Error(`no transitions defined for role "${role}"`),
};
}
for (const transition of transitions) {
if (transition.condition === null) {
return { ok: true, value: transition.role };
}
const conditionDef = workflow.conditions[transition.condition];
if (conditionDef === undefined) {
return {
ok: false,
error: new Error(`unknown condition "${transition.condition}"`),
};
}
const evalResult = await evaluateJsonata(conditionDef.expression, context);
if (!evalResult.ok) {
return evalResult;
}
if (isTruthy(evalResult.value)) {
return { ok: true, value: transition.role };
}
}
return {
ok: false,
error: new Error(`no transition matched for role "${role}"`),
};
}
-1
View File
@@ -1 +0,0 @@
export { evaluate } from "./evaluate.js";
-1
View File
@@ -1 +0,0 @@
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"],
"references": [{ "path": "../uwf-protocol" }]
}
-26
View File
@@ -1,26 +0,0 @@
{
"name": "@uncaged/uwf-protocol",
"version": "0.1.0",
"files": [
"src",
"dist",
"package.json"
],
"type": "module",
"exports": {
".": {
"bun": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"dependencies": {
"@uncaged/json-cas-fs": "^0.3.0"
},
"devDependencies": {
"typescript": "^5.8.3"
},
"publishConfig": {
"access": "public"
}
}
-36
View File
@@ -1,36 +0,0 @@
export {
START_NODE_SCHEMA,
STEP_NODE_SCHEMA,
WORKFLOW_SCHEMA,
} from "./schemas.js";
export type {
AgentAlias,
AgentConfig,
CasRef,
ConditionDefinition,
ModelAlias,
ModelConfig,
ModeratorContext,
ProviderAlias,
ProviderConfig,
RoleDefinition,
RoleName,
Scenario,
StartEntry,
StartNodePayload,
StartOutput,
StepContext,
StepEntry,
StepNodePayload,
StepOutput,
StepRecord,
ThreadForkOutput,
ThreadId,
ThreadListItem,
ThreadStepsOutput,
ThreadsIndex,
Transition,
WorkflowConfig,
WorkflowName,
WorkflowPayload,
} from "./types.js";
-86
View File
@@ -1,86 +0,0 @@
import type { JSONSchema } from "@uncaged/json-cas";
const ROLE_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "systemPrompt", "outputSchema"],
properties: {
description: { type: "string" },
systemPrompt: { type: "string" },
outputSchema: { type: "string", format: "cas_ref" },
},
additionalProperties: false,
};
const CONDITION_DEFINITION: JSONSchema = {
type: "object",
required: ["description", "expression"],
properties: {
description: { type: "string" },
expression: { type: "string" },
},
additionalProperties: false,
};
const TRANSITION: JSONSchema = {
type: "object",
required: ["role", "condition"],
properties: {
role: { type: "string" },
condition: { anyOf: [{ type: "string" }, { type: "null" }] },
},
additionalProperties: false,
};
export const WORKFLOW_SCHEMA: JSONSchema = {
title: "Workflow",
type: "object",
required: ["name", "description", "roles", "conditions", "graph"],
properties: {
name: { type: "string" },
description: { type: "string" },
roles: {
type: "object",
additionalProperties: ROLE_DEFINITION,
},
conditions: {
type: "object",
additionalProperties: CONDITION_DEFINITION,
},
graph: {
type: "object",
additionalProperties: {
type: "array",
items: TRANSITION,
},
},
},
additionalProperties: false,
};
export const START_NODE_SCHEMA: JSONSchema = {
title: "StartNode",
type: "object",
required: ["workflow", "prompt"],
properties: {
workflow: { type: "string", format: "cas_ref" },
prompt: { type: "string" },
},
additionalProperties: false,
};
export const STEP_NODE_SCHEMA: JSONSchema = {
title: "StepNode",
type: "object",
required: ["start", "prev", "role", "output", "detail", "agent"],
properties: {
start: { type: "string", format: "cas_ref" },
prev: {
anyOf: [{ type: "string", format: "cas_ref" }, { type: "null" }],
},
role: { type: "string" },
output: { type: "string", format: "cas_ref" },
detail: { type: "string", format: "cas_ref" },
agent: { type: "string" },
},
additionalProperties: false,
};
-161
View File
@@ -1,161 +0,0 @@
// ── 4.1 公共类型 ────────────────────────────────────────────────────
/** CAS hash — XXH64, 13-char Crockford Base32 */
export type CasRef = string;
/** Thread ID — ULID, 26-char Crockford Base32 */
export type ThreadId = string;
/** 一个 step 的核心数据,被 StepNode payload 和 JSONata 上下文共享 */
export type StepRecord = {
role: string;
output: CasRef;
detail: CasRef;
agent: string;
};
// ── 4.2 Workflow 定义 ───────────────────────────────────────────────
export type RoleDefinition = {
description: string;
systemPrompt: string;
outputSchema: CasRef;
};
export type Transition = {
role: string;
condition: string | null;
};
export type ConditionDefinition = {
description: string;
expression: string;
};
export type WorkflowPayload = {
name: string;
description: string;
roles: Record<string, RoleDefinition>;
conditions: Record<string, ConditionDefinition>;
graph: Record<string, Transition[]>;
};
// ── 4.3 Thread 节点 ─────────────────────────────────────────────────
export type StartNodePayload = {
workflow: CasRef;
prompt: string;
};
export type StepNodePayload = StepRecord & {
start: CasRef;
prev: CasRef | null;
};
// ── 4.4 JSONata 求值上下文 ──────────────────────────────────────────
/** JSONata 上下文中的 step — output 被展开 */
export type StepContext = Omit<StepRecord, "output"> & {
output: unknown;
};
export type ModeratorContext = {
start: StartNodePayload;
steps: StepContext[];
};
// ── 4.5 CLI 输出 ────────────────────────────────────────────────────
/** uwf thread start */
export type StartOutput = {
workflow: CasRef;
thread: ThreadId;
};
/** uwf thread step / uwf thread show */
export type StepOutput = {
workflow: CasRef;
thread: ThreadId;
head: CasRef;
done: boolean;
};
/** uwf thread steps — single step entry */
export type StepEntry = {
hash: CasRef;
role: string;
output: unknown;
detail: CasRef;
agent: string;
timestamp: number;
};
/** uwf thread steps — start entry */
export type StartEntry = {
hash: CasRef;
workflow: CasRef;
prompt: string;
timestamp: number;
};
/** uwf thread steps output */
export type ThreadStepsOutput = {
thread: ThreadId;
workflow: CasRef;
steps: [StartEntry, ...StepEntry[]];
};
/** uwf thread fork output */
export type ThreadForkOutput = {
thread: ThreadId;
forkedFrom: {
thread: ThreadId;
step: CasRef;
};
};
/** uwf thread list */
export type ThreadListItem = {
thread: ThreadId;
workflow: CasRef;
head: CasRef;
};
// ── 4.6 配置 ────────────────────────────────────────────────────────
/** Alias types for config references */
export type AgentAlias = string;
export type ModelAlias = string;
export type ProviderAlias = string;
export type WorkflowName = string;
export type RoleName = string;
export type Scenario = string;
export type ProviderConfig = {
baseUrl: string;
apiKeyEnv: string;
};
export type ModelConfig = {
provider: ProviderAlias;
name: string;
};
export type AgentConfig = {
command: string;
args: string[];
};
/** ~/.uncaged/workflow/config.yaml */
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;
};
/** ~/.uncaged/workflow/threads.yaml */
export type ThreadsIndex = Record<ThreadId, CasRef>;
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-cursor");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command' and 'timeout'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
expect(required).toContain("timeout");
});
test("configSchema properties include command, model, timeout, workspace", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
expect(props).toHaveProperty("workspace");
});
});
@@ -11,6 +11,7 @@ import { extractWorkspacePath } from "./extract-workspace.js";
import type { CursorAgentConfig } from "./types.js";
import { validateCursorAgentConfig } from "./validate-config.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { CursorAgentConfig } from "./types.js";
export { validateCursorAgentConfig } from "./validate-config.js";
@@ -0,0 +1,34 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-cursor.
* Config maps to {@link CursorAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-cursor",
version: "0.5.0-alpha.4",
capabilities: ["cursor-cli", "workspace-agent"],
configSchema: {
type: "object",
required: ["command", "timeout"],
properties: {
command: {
type: "string",
description: "Absolute path to the cursor-agent CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Model identifier passed to cursor-agent --model; null means auto.",
},
timeout: {
type: "number",
description: "Timeout in milliseconds; 0 means no limit.",
},
workspace: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Override workspace path; null resolves from thread context.",
},
},
additionalProperties: false,
},
};
@@ -1,22 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createDocxDiffAgent } from "../src/agent.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,113 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } 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,29 +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,29 +0,0 @@
import * as z from "zod/v4";
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 { 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,47 +0,0 @@
import { stat } from "node:fs/promises";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } 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" }
]
}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-hermes");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires 'command'", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("command");
});
test("configSchema properties include command, model, timeout", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("command");
expect(props).toHaveProperty("model");
expect(props).toHaveProperty("timeout");
});
});
@@ -13,6 +13,7 @@ const HERMES_DEFAULT_MAX_TURNS = 90;
type HermesAgentOpt = { prompt: string };
export { packageDescriptor } from "./package-descriptor.js";
export type { HermesAgentConfig } from "./types.js";
export { validateHermesAgentConfig } from "./validate-config.js";
@@ -1,20 +1,24 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-hermes.
* Config maps to {@link HermesAgentConfig}.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-office",
version: "0.1.0",
capabilities: ["office-agent-cli", "docx-generate", "docx-edit"],
name: "@uncaged/workflow-agent-hermes",
version: "0.5.0-alpha.4",
capabilities: ["hermes-cli", "yolo-mode"],
configSchema: {
type: "object",
required: ["outputDir"],
required: ["command"],
properties: {
outputDir: {
type: "string",
description: "Root directory for workflow outputs; subdirs are created per threadId.",
},
command: {
type: "string",
description: "Absolute path to the hermes CLI binary.",
},
model: {
anyOf: [{ type: "string" }, { type: "null" }],
description: "Path to office-agent CLI binary; null uses PATH.",
description: "Model identifier passed to hermes --model; null uses the CLI default.",
},
timeout: {
anyOf: [{ type: "number" }, { type: "null" }],
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-llm");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires baseUrl, apiKey, model", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("baseUrl");
expect(required).toContain("apiKey");
expect(required).toContain("model");
});
test("configSchema properties include baseUrl, apiKey, model", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("baseUrl");
expect(props).toHaveProperty("apiKey");
expect(props).toHaveProperty("model");
});
});
+1
View File
@@ -4,3 +4,4 @@ export {
type LlmChatError,
type LlmMessage,
} from "./create-llm-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
@@ -0,0 +1,30 @@
import type { PackageDescriptor } from "@uncaged/workflow-runtime";
/**
* Static metadata for @uncaged/workflow-agent-llm.
* Config maps to {@link LlmProvider}: baseUrl + apiKey + model.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-llm",
version: "0.5.0-alpha.4",
capabilities: ["llm-single-turn"],
configSchema: {
type: "object",
required: ["baseUrl", "apiKey", "model"],
properties: {
baseUrl: {
type: "string",
description: "Base URL of the OpenAI-compatible chat completions endpoint.",
},
apiKey: {
type: "string",
description: "API key for the provider.",
},
model: {
type: "string",
description: "Model identifier passed as the `model` field in the request body.",
},
},
additionalProperties: false,
},
};
@@ -1,27 +0,0 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/package-descriptor.js";
import { createOfficeAgent } from "../src/agent.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,129 +0,0 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, mock, test } from "bun:test";
import { ok, err } 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,25 +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,44 +0,0 @@
import * as z from "zod/v4";
import type { AdapterFn, RoleResult, ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
import { createLogger } from "@uncaged/workflow-util";
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,65 +0,0 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { spawnCli } from "@uncaged/workflow-util-agent";
import type { SpawnCliError } 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" }
]
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test";
import { packageDescriptor } from "../src/index.js";
describe("packageDescriptor", () => {
test("has the correct package name", () => {
expect(packageDescriptor.name).toBe("@uncaged/workflow-agent-react");
});
test("has a non-empty version string", () => {
expect(typeof packageDescriptor.version).toBe("string");
expect(packageDescriptor.version.length).toBeGreaterThan(0);
});
test("capabilities is a non-empty array of strings", () => {
expect(Array.isArray(packageDescriptor.capabilities)).toBe(true);
expect(packageDescriptor.capabilities.length).toBeGreaterThan(0);
for (const cap of packageDescriptor.capabilities) {
expect(typeof cap).toBe("string");
}
});
test("configSchema is an object with type 'object'", () => {
expect(typeof packageDescriptor.configSchema).toBe("object");
expect(packageDescriptor.configSchema.type).toBe("object");
});
test("configSchema requires maxRounds", () => {
const required = packageDescriptor.configSchema.required as string[];
expect(required).toContain("maxRounds");
});
test("configSchema properties include maxRounds", () => {
const props = packageDescriptor.configSchema.properties as Record<string, unknown>;
expect(props).toHaveProperty("maxRounds");
});
});
@@ -1,4 +1,5 @@
export { createReactAdapter } from "./create-react-adapter.js";
export { packageDescriptor } from "./package-descriptor.js";
export type { ToolEntry, ToolHandler } from "./tools/index.js";
export { defaultToolHandler, defaultTools } from "./tools/index.js";
export type { ReactAdapterConfig, ReactToolHandler } from "./types.js";
@@ -0,0 +1,25 @@
import type { PackageDescriptor } from "@uncaged/workflow-protocol";
/**
* Static metadata for @uncaged/workflow-agent-react.
*
* Config represents the serializable subset of {@link ReactAdapterConfig}.
* The `llm` function and `toolHandler` are runtime constructs and are not
* stored in the CAS agent node; only `maxRounds` is serializable.
*/
export const packageDescriptor: PackageDescriptor = {
name: "@uncaged/workflow-agent-react",
version: "0.5.0-alpha.4",
capabilities: ["react-loop", "tool-calling"],
configSchema: {
type: "object",
required: ["maxRounds"],
properties: {
maxRounds: {
type: "number",
description: "Maximum number of LLM ↔ tool-call rounds before the loop is terminated.",
},
},
additionalProperties: false,
},
};
-8
View File
@@ -4,14 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
<script>
(function () {
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>
+1 -13
View File
@@ -13,23 +13,11 @@
"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"
"shiki": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
@@ -1,362 +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 { viteLimitLinePlugin };
export type { LimitLineOptions, LimitLineOverride };
+49 -12
View File
@@ -1,38 +1,75 @@
import { useState } from "react";
import { Navigate, Outlet, useParams } from "react-router";
import { clearApiKey, hasApiKey } from "./api.ts";
import { LoginPage } from "./components/login.tsx";
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";
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";
import { useHashRoute } from "./use-hash-route.ts";
export function Layout() {
export function App() {
const [authed, setAuthed] = useState(hasApiKey());
const { client } = useParams();
const { view, client, threadId, workflowName, setView, setClient, setThreadId, setWorkflowName } =
useHashRoute();
const [showRun, setShowRun] = useState(false);
const { theme, toggleTheme } = useTheme();
if (!authed) {
return <Navigate to="/login" replace />;
return <LoginPage onLogin={() => setAuthed(true)} />;
}
return (
<div className="flex h-screen bg-background">
<div className="flex h-screen">
<Sidebar
view={view}
client={client}
onViewChange={setView}
onClientChange={setClient}
onLogout={() => {
clearApiKey();
setAuthed(false);
}}
theme={theme}
onToggleTheme={toggleTheme}
/>
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar client={client ?? null} onRun={() => setShowRun(true)} />
<StatusBar client={client} onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
<Outlet />
{!client && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an client from the sidebar to get started.
</p>
</div>
)}
{client && view === "threads" && threadId === null && (
<ThreadList client={client} onSelect={setThreadId} />
)}
{client && view === "threads" && threadId !== null && (
<ThreadDetail client={client} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{client && view === "workflows" && workflowName === null && (
<WorkflowList client={client} onSelect={setWorkflowName} />
)}
{client && view === "workflows" && workflowName !== null && (
<WorkflowDetail
client={client}
workflowName={workflowName}
onBack={() => setWorkflowName(null)}
/>
)}
</div>
</main>
{client && <RunDialog client={client} open={showRun} onOpenChange={setShowRun} />}
{showRun && client && (
<RunDialog
client={client}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setThreadId(id);
}}
/>
)}
</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,18 +1,14 @@
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();
type Props = {
onLogin: () => void;
};
export function LoginPage({ onLogin }: Props) {
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();
@@ -21,6 +17,7 @@ export function LoginPage() {
setLoading(true);
setError(null);
// Test the key by hitting the endpoints list
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
try {
const res = await fetch(`${gatewayUrl}/api/gateway/endpoints`, {
@@ -43,59 +40,56 @@ export function LoginPage() {
}
setApiKey(key.trim());
navigate("/", { replace: true });
onLogin();
}
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}
<div
className="min-h-screen flex items-center justify-center"
style={{ background: "var(--color-bg)" }}
>
<div
className="p-8 rounded-lg border w-full max-w-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
{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>
<h1 className="text-xl font-bold mb-1" style={{ color: "var(--color-accent)" }}>
Workflow Dashboard
</h1>
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
Enter your API key to continue
</p>
<form onSubmit={handleSubmit}>
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="API Key"
className="w-full px-3 py-2 rounded border text-sm mb-3 outline-none"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
{error && (
<p className="text-xs mb-3" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !key.trim()}
className="w-full px-3 py-2 rounded text-sm font-medium"
style={{
background: "var(--color-accent)",
color: "var(--color-bg)",
opacity: loading || !key.trim() ? 0.5 : 1,
}}
>
{loading ? "Verifying..." : "Login"}
</button>
</form>
</div>
</div>
);
}
@@ -52,23 +52,19 @@ function CodeBlock({ className, children }: { className?: string; children?: Rea
if (html !== null) {
return (
<div className="relative rounded-lg border border-border overflow-hidden my-3">
{lang !== "text" && (
<span className="absolute top-2 right-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 font-mono">
{lang}
</span>
)}
<div
className="overflow-x-auto text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
<div
className="rounded overflow-x-auto text-xs my-2"
// biome-ignore lint/security/noDangerouslySetInnerHtml: shiki output is safe
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre className="rounded-lg overflow-x-auto text-xs my-3 p-3 bg-muted/50 border border-border">
<pre
className="rounded overflow-x-auto text-xs my-2 p-3"
style={{ background: "var(--color-bg)" }}
>
<code>{code}</code>
</pre>
);
@@ -84,7 +80,8 @@ export function Markdown({ content }: { content: string }) {
if (isInline) {
return (
<code
className="bg-muted rounded px-1.5 py-0.5 text-[13px] font-mono text-foreground"
className="text-xs px-1 py-0.5 rounded"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
{...props}
>
{children}
@@ -94,7 +91,7 @@ export function Markdown({ content }: { content: string }) {
return <CodeBlock className={className}>{children}</CodeBlock>;
},
p({ children }) {
return <p className="my-2 leading-relaxed">{children}</p>;
return <p className="my-1.5 leading-relaxed">{children}</p>;
},
ul({ children }) {
return <ul className="list-disc pl-4 my-1.5">{children}</ul>;
@@ -103,25 +100,20 @@ export function Markdown({ content }: { content: string }) {
return <ol className="list-decimal pl-4 my-1.5">{children}</ol>;
},
h1({ children }) {
return (
<h1 className="text-lg font-bold mt-3 mb-2 border-b border-border pb-1">
{children}
</h1>
);
return <h1 className="text-lg font-bold mt-3 mb-1">{children}</h1>;
},
h2({ children }) {
return (
<h2 className="text-base font-bold mt-2 mb-2 border-b border-border pb-1">
{children}
</h2>
);
return <h2 className="text-base font-bold mt-2 mb-1">{children}</h2>;
},
h3({ children }) {
return <h3 className="text-sm font-bold mt-2 mb-1">{children}</h3>;
},
blockquote({ children }) {
return (
<blockquote className="border-l-2 border-ring pl-3 my-2 text-sm text-muted-foreground bg-muted/30 rounded-r-md py-2">
<blockquote
className="border-l-2 pl-3 my-2 text-sm"
style={{ borderColor: "var(--color-accent)", color: "var(--color-text-muted)" }}
>
{children}
</blockquote>
);
@@ -1,26 +1,14 @@
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];
const ROLE_COLORS: Record<string, string> = {
preparer: "#8b5cf6",
client: "#3b82f6",
extractor: "#f59e0b",
};
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 roleColor(role: string): string {
return ROLE_COLORS[role] ?? "var(--color-accent)";
}
function formatTime(ts: number | null): string | null {
@@ -30,86 +18,99 @@ function formatTime(ts: number | null): string | null {
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="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<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"}>
<span className="text-lg">🚀</span>
<span className="font-semibold" style={{ color: "var(--color-accent)" }}>
{record.workflow}
</span>
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background: record.status === "active" ? "var(--color-success)" : "var(--color-border)",
color: record.status === "active" ? "var(--color-bg)" : "var(--color-text-muted)",
}}
>
{record.status}
</Badge>
</span>
</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" />
<div
className="mt-2 p-3 rounded text-sm border-l-2"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-accent)",
color: "var(--color-text)",
}}
>
<div className="text-xs mb-1" style={{ color: "var(--color-text-muted)" }}>
Prompt
</div>
<Markdown content={record.prompt} />
</div>
)}
</Card>
</div>
);
}
function RoleMessage({ record, highlighted }: { record: RoleRecord; highlighted: boolean }) {
const style = roleBadgeStyle(record.role);
const color = roleColor(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={`p-3 rounded-lg border text-sm ${highlighted ? "wf-record-card-highlight" : ""}`}
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<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 }}
className="text-xs px-2 py-0.5 rounded font-mono font-medium"
style={{ background: color, color: "#fff" }}
>
<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" />
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</Card>
</div>
);
}
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="p-4 rounded-lg border"
style={{
background: "var(--color-surface)",
borderColor: success ? "var(--color-success)" : "var(--color-error)",
}}
>
<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="text-lg">{success ? "✅" : "❌"}</span>
<span className="font-semibold text-sm">{success ? "Completed" : "Failed"}</span>
<Badge variant="outline" className="font-mono">
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: success ? "var(--color-success)" : "var(--color-error)",
color: "#fff",
}}
>
exit {record.returnCode}
</Badge>
</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" />
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{formatTime(record.timestamp)}
</span>
)}
</div>
<Markdown content={record.content} />
</Card>
</div>
);
}
@@ -1,27 +1,14 @@
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;
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ client, open, onOpenChange }: Props) {
const navigate = useNavigate();
export function RunDialog({ client, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(client), [client]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
@@ -35,8 +22,7 @@ export function RunDialog({ client, open, onOpenChange }: Props) {
setError(null);
try {
const result = await runThread(client, workflow, prompt);
onOpenChange(false);
navigate(`/${client}/threads/${result.threadId}`);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
@@ -44,54 +30,95 @@ export function RunDialog({ client, open, onOpenChange }: Props) {
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Run Thread</DialogTitle>
<DialogDescription>Start a new thread on {client}</DialogDescription>
</DialogHeader>
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread on {client}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="run-workflow" className="text-sm block mb-1.5 text-muted-foreground">
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
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>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
</div>
<div>
<label htmlFor="run-prompt" className="text-sm block mb-1.5 text-muted-foreground">
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt
</label>
<Textarea
<textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..."
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Cancel
</Button>
<Button type="submit" disabled={submitting || !workflow || !prompt}>
</button>
<button
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
{submitting ? "Starting..." : "Run"}
</Button>
</DialogFooter>
</button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
);
}
@@ -1,131 +1,109 @@
import { Loader2, LogOut, Moon, Package, Sun, Zap } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router";
import { useEffect } from "react";
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 = {
view: "threads" | "workflows";
client: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onClientChange: (a: string | null) => void;
onLogout: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
};
export function Sidebar({ onLogout, theme, onToggleTheme }: Props) {
const { client } = useParams();
const navigate = useNavigate();
const location = useLocation();
export function Sidebar({ view, client, onViewChange, onClientChange, onLogout }: Props) {
const { status, data } = useFetch(() => listClients(), []);
const clients: ClientEndpoint[] = status === "ok" ? data : [];
const view = location.pathname.includes("/workflows") ? "workflows" : "threads";
// Auto-select first client when none is selected
useEffect(() => {
if (client === null && clients.length > 0) {
onClientChange(clients[0].name);
}
}, [client, clients, onClientChange]);
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: Zap },
{ key: "workflows" as const, label: "Workflows", icon: Package },
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
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>
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div>
<div className="px-3 py-3">
{/* Client selector */}
<div className="px-4 py-3 border-b" style={{ borderColor: "var(--color-border)" }}>
<label
className="block text-xs font-medium mb-1.5 text-muted-foreground"
className="block text-xs font-medium mb-1"
style={{ color: "var(--color-text-muted)" }}
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>
)}
<select
id="client-select"
className="w-full rounded px-2 py-1.5 text-xs"
style={{
background: "var(--color-bg)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
value={client ?? ""}
onChange={(e) => onClientChange(e.target.value || null)}
disabled={status === "loading"}
>
{status === "loading" ? (
<option value="">Loading</option>
) : clients.length === 0 ? (
<option value="">No clients online</option>
) : (
clients.map((a) => (
<option key={a.name} value={a.name}>
{a.status === "online" ? "🟢" : "🔴"} {a.name}
</option>
))
)}
</select>
</div>
<Separator />
{/* View navigation */}
<nav className="flex-1 p-2 space-y-1">
{viewItems.map((item) => (
<Button
<button
type="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}`);
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}}
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
{item.icon} {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"
<div className="p-2 border-t" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={onLogout}
className="w-full text-left px-3 py-2 rounded text-xs transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<LogOut className="h-4 w-4" />
Logout
</Button>
🚪 Logout
</button>
</div>
</aside>
);
@@ -1,7 +1,5 @@
import { Loader2, Play, Wifi, WifiOff } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClientHealth } from "../api.ts";
import { Button } from "./ui/button.tsx";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
@@ -10,29 +8,14 @@ type Props = {
onRun: () => void;
};
function StatusIndicator({ status }: { status: HealthStatus }) {
function statusLabel(status: HealthStatus): { text: string; color: string } {
if (status === "connected") {
return (
<span className="flex items-center gap-1.5 text-xs text-success transition-colors duration-200">
<Wifi className="h-3.5 w-3.5" />
Connected
</span>
);
return { text: "● Connected", color: "var(--color-success)" };
}
if (status === "reconnecting") {
return (
<span className="flex items-center gap-1.5 text-xs text-warning transition-colors duration-200">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Reconnecting
</span>
);
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
}
return (
<span className="flex items-center gap-1.5 text-xs text-destructive transition-colors duration-200">
<WifiOff className="h-3.5 w-3.5" />
Offline
</span>
);
return { text: "● Offline", color: "var(--color-error)" };
}
export function StatusBar({ client, onRun }: Props) {
@@ -65,24 +48,32 @@ export function StatusBar({ client, onRun }: Props) {
return () => clearInterval(interval);
}, [checkHealth]);
const label = statusLabel(status);
return (
<div className="flex items-center justify-between px-6 py-2 text-xs border-b border-border bg-card/80 backdrop-blur-sm">
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4">
<span className="text-muted-foreground">
<span style={{ color: "var(--color-text-muted)" }}>
{client ? `Client: ${client}` : "No client selected"}
</span>
<Button
variant="default"
size="sm"
disabled={!client}
<button
type="button"
onClick={onRun}
className="h-7 gap-1.5 transition-all duration-200"
disabled={!client}
className="px-3 py-1 rounded text-xs font-medium"
style={{
background: client ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: client ? 1 : 0.5,
}}
>
<Play className="h-3.5 w-3.5" />
Run Thread
</Button>
Run Thread
</button>
</div>
<StatusIndicator status={status} />
<span style={{ color: label.color }}>{label.text}</span>
</div>
);
}

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