Compare commits
273 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94c719870f | |||
| 5af2d54e0f | |||
| e01c08dacb | |||
| f9d3d38008 | |||
| 9e99e58405 | |||
| 6af3059fb4 | |||
| dfeba9d8fc | |||
| 0da1aabfab | |||
| bb3618cc42 | |||
| 2b21d981dd | |||
| ebfb99bf4c | |||
| 33f9425848 | |||
| 2b707fb44e | |||
| 6306b23a9f | |||
| 6bb8cf8315 | |||
| 93b7947d7c | |||
| 9584a86fb7 | |||
| defc0afc27 | |||
| 9f6633d5bf | |||
| 7dadf874e1 | |||
| ba90214af6 | |||
| 5bbac3e4f7 | |||
| 131021b1a7 | |||
| e42555fd9c | |||
| 3a26eb28e5 | |||
| c1a17b707c | |||
| 4ea1e0d8a4 | |||
| b1a9d2ec3f | |||
| 2b8707a706 | |||
| 241bfbf6d9 | |||
| 40530d757e | |||
| 0f3661b566 | |||
| 9c44c709e9 | |||
| 8892ab9978 | |||
| 7ec86d82a3 | |||
| f728b36e8d | |||
| 3431d3070b | |||
| 576df067d4 | |||
| a46a225d04 | |||
| f74b482cc0 | |||
| 89abfdc257 | |||
| 77e395b913 | |||
| b65a006d45 | |||
| 5994548f0b | |||
| 0871ae54ea | |||
| 9576d69032 | |||
| 64dadf114d | |||
| baaa1d1dc8 | |||
| 3074cd5f0c | |||
| 15edc99c72 | |||
| 153178c545 | |||
| fac215bd21 | |||
| 9822e68c55 | |||
| 764b73209e | |||
| e7987c4cd7 | |||
| 942ff4b1a4 | |||
| f5977c46c6 | |||
| 71ccf8d03c | |||
| 510b49287a | |||
| bb6b309efa | |||
| 56db22a908 | |||
| 2a1b7b0aeb | |||
| d037eca4ae | |||
| b9d543a465 | |||
| 07f52594d1 | |||
| c7b426ff5a | |||
| 4582274ba4 | |||
| d140801337 | |||
| 4563f1bb5e | |||
| 59b7e89028 | |||
| 019d8c1ee9 | |||
| 5e783e7a24 | |||
| a450a88b16 | |||
| 5b47317cef | |||
| 3384c38d02 | |||
| b370d96504 | |||
| 8cae114c7e | |||
| c2c6fc5304 | |||
| 94f725c50b | |||
| 9b23e6f85a | |||
| 238a94f7a6 | |||
| 236c771e4e | |||
| 0ffd84cf7d | |||
| e14643a50b | |||
| 76830c5e22 | |||
| 90a388f5ab | |||
| 82e40f0c21 | |||
| 8d650326db | |||
| dd3eec7d35 | |||
| 9276689cb6 | |||
| b4584cbaa6 | |||
| 1cf963a1fb | |||
| ce5bc50210 | |||
| 439e203113 | |||
| 522afdd4bd | |||
| ca644dabaa | |||
| 9d9c00df98 | |||
| a1c5dc3e92 | |||
| c85980f604 | |||
| eff5fb332a | |||
| 658a4a24ef | |||
| aabfd90a87 | |||
| 0207f93303 | |||
| e1423f196b | |||
| ae6954a02f | |||
| aede8f7613 | |||
| 6d1e0498ba | |||
| 6cce5e2593 | |||
| d3a7ed9062 | |||
| e7f733c393 | |||
| d4bb4a9324 | |||
| e4900b6fd6 | |||
| 39540d9ae8 | |||
| 10899364d4 | |||
| dc5fdd7358 | |||
| bb1293f6b9 | |||
| 55b3b61498 | |||
| 484ed520cd | |||
| 497f03c747 | |||
| cfe4543d39 | |||
| 399b967c59 | |||
| 061926b86a | |||
| acb0ebed97 | |||
| d5d7be6100 | |||
| 1566a43395 | |||
| afbde4573a | |||
| 63e447fc3d | |||
| 34fcbf29cb | |||
| 256799fcfd | |||
| 21cf3db111 | |||
| ed38543db4 | |||
| 78771fbebc | |||
| c15f58bdeb | |||
| 6d4bf108bb | |||
| 5b7c9b844b | |||
| f0d1bb9ae8 | |||
| 04cfd33f99 | |||
| a8c00f169b | |||
| c4d34530e8 | |||
| 90a410c00a | |||
| 6276ca5a4a | |||
| 8e63f99eb6 | |||
| 9ca70bbb69 | |||
| ed1f38c7da | |||
| 1664d68b50 | |||
| 1871ef31b4 | |||
| ec3c97b200 | |||
| 18e3dc7603 | |||
| fc229cac79 | |||
| ec555b43d1 | |||
| c8de86d7c9 | |||
| bd110b76e1 | |||
| dc10ccceaa | |||
| c040a90a8f | |||
| ec4599a230 | |||
| 1f4bd3f431 | |||
| bebf4aad45 | |||
| 11ba185fef | |||
| 730340d123 | |||
| c848216396 | |||
| 2698e0a6cb | |||
| 47f2b1a128 | |||
| 0c02cb7574 | |||
| 320810ec25 | |||
| 91f585c534 | |||
| 299ff126d9 | |||
| 931eb81458 | |||
| c604d1f600 | |||
| 20bcc65f61 | |||
| f5612ef1b5 | |||
| a92deeaf3f | |||
| 1e936cf04a | |||
| ea16057803 | |||
| 4493fd8979 | |||
| cc1ee8d5e3 | |||
| 0ad5c85f5a | |||
| d02d410dcd | |||
| cdf3c95622 | |||
| a7fea10383 | |||
| 3846dc12a9 | |||
| c5fd84432f | |||
| 4c4dabb7a3 | |||
| 1b62cec0a2 | |||
| ecc348f182 | |||
| 41209f1ef8 | |||
| 58a4aefcc4 | |||
| bbb79f821e | |||
| 05fbd4f5b5 | |||
| 7e7331eb2d | |||
| 0fbbf37548 | |||
| 2af39463de | |||
| 5f2458238f | |||
| aadec0b96c | |||
| 1c68ce6217 | |||
| 7265603b55 | |||
| 74cea09ac0 | |||
| b1e66fa7a4 | |||
| 81a7a8c7c1 | |||
| 9cb7d68abe | |||
| 98122b446d | |||
| 4a31cf9d63 | |||
| 2c26be6ec6 | |||
| f723daa014 | |||
| 1e9900bed3 | |||
| aebff8b906 | |||
| db45089922 | |||
| 9c1b018ffa | |||
| a98431a12a | |||
| 0fe17b0fb2 | |||
| e37dbc3f35 | |||
| 82d9abf260 | |||
| 50aec2d0cf | |||
| e979a55f8a | |||
| 30f1582046 | |||
| cf0540d7fa | |||
| c05fac746c | |||
| 34efd25e91 | |||
| cc0bc6c8aa | |||
| 626cb5d98e | |||
| f87cb38a67 | |||
| 0970139418 | |||
| 376dd87b6b | |||
| 4d8469a649 | |||
| a929fa4ccb | |||
| ff3e19fd22 | |||
| b509d1715e | |||
| b93f6e736f | |||
| ec13c19505 | |||
| 203b86e827 | |||
| 90de1c7025 | |||
| 2b587612d5 | |||
| 2342a6e3bd | |||
| 0021596ff0 | |||
| 56ec8cd401 | |||
| fe87efd79d | |||
| b783027406 | |||
| 904ee1eb83 | |||
| 1742ced6df | |||
| 93145cf08c | |||
| da6bcb10d6 | |||
| 6fc97fc8c8 | |||
| 93d9821f64 | |||
| 29367cbe31 | |||
| ec397aecd3 | |||
| 2e9d939f8e | |||
| 064a24f093 | |||
| fede623a82 | |||
| 2a52b930b9 | |||
| bf2f790e6e | |||
| 08a79b77db | |||
| 22a6200b69 | |||
| 7e7f6aa6d6 | |||
| d6fe3f844c | |||
| d0803019b5 | |||
| f16e7641fd | |||
| 3b41625001 | |||
| c602d2284b | |||
| d96e10b0fc | |||
| 8e36d3e1f5 | |||
| bbe4fe0ed1 | |||
| e105c5cac1 | |||
| 578776fccf | |||
| cb756a999a | |||
| e0577ceefe | |||
| 024dd8c1e8 | |||
| 9e98119145 | |||
| fd8943f131 | |||
| f7253d5948 | |||
| 1c5636c270 | |||
| ca0403c8ab | |||
| aa25f55f63 | |||
| e29d1bf345 | |||
| f3aedf8d6c |
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets).
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [["@uncaged/*"]],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@uncaged/workflow-dashboard"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util": patch
|
||||
---
|
||||
|
||||
Replace optionalEnv/requireEnv with unified env(name, fallback) API
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: correct internal dependency versions for prerelease
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-util-agent": patch
|
||||
---
|
||||
|
||||
fix: include create-agent-adapter.ts in published src
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": patch
|
||||
---
|
||||
|
||||
fix: use npm publish with pinned deps instead of bun publish (workspace:^ resolution bug)
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "alpha",
|
||||
"initialVersions": {
|
||||
"@uncaged/cli-workflow": "0.4.5",
|
||||
"@uncaged/workflow-agent-cursor": "0.4.5",
|
||||
"@uncaged/workflow-agent-hermes": "0.4.5",
|
||||
"@uncaged/workflow-agent-llm": "0.4.5",
|
||||
"@uncaged/workflow-agent-react": "0.4.5",
|
||||
"@uncaged/workflow-cas": "0.4.5",
|
||||
"@uncaged/workflow-dashboard": "0.1.0",
|
||||
"@uncaged/workflow-execute": "0.4.5",
|
||||
"@uncaged/workflow-gateway": "0.4.5",
|
||||
"@uncaged/workflow-protocol": "0.4.5",
|
||||
"@uncaged/workflow-reactor": "0.4.5",
|
||||
"@uncaged/workflow-register": "0.4.5",
|
||||
"@uncaged/workflow-runtime": "0.4.5",
|
||||
"@uncaged/workflow-template-develop": "0.4.5",
|
||||
"@uncaged/workflow-template-solve-issue": "0.4.5",
|
||||
"@uncaged/workflow-util": "0.4.5",
|
||||
"@uncaged/workflow-util-agent": "0.4.5"
|
||||
},
|
||||
"changesets": [
|
||||
"env-api-unify",
|
||||
"fix-internal-deps",
|
||||
"fix-publish-src",
|
||||
"fix-workspace-deps",
|
||||
"rfc-252-agent-fn"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@uncaged/workflow-protocol": minor
|
||||
---
|
||||
|
||||
feat: AgentFn<Opt> type boundary and createAgentAdapter bridging function (RFC #252)
|
||||
@@ -0,0 +1,40 @@
|
||||
# ──────────────────────────────────────────────
|
||||
# Workflow Engine — Environment Variables
|
||||
# ──────────────────────────────────────────────
|
||||
# Copy this file to .env and fill in the values.
|
||||
|
||||
# ── Cursor Agent ──
|
||||
|
||||
# CLI command to invoke the Cursor agent (required for develop workflow)
|
||||
WORKFLOW_CURSOR_COMMAND=
|
||||
|
||||
# Model override for Cursor agent
|
||||
WORKFLOW_CURSOR_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Cursor agent operations
|
||||
WORKFLOW_CURSOR_TIMEOUT=
|
||||
|
||||
# ── Hermes Agent (used by develop tester/committer + solve-issue) ──
|
||||
|
||||
# CLI command to invoke the Hermes agent (absolute path required)
|
||||
WORKFLOW_HERMES_COMMAND=
|
||||
|
||||
# Model override for Hermes agent
|
||||
WORKFLOW_HERMES_MODEL=
|
||||
|
||||
# Timeout in milliseconds for Hermes agent operations
|
||||
WORKFLOW_HERMES_TIMEOUT=
|
||||
|
||||
# ── Storage ──
|
||||
|
||||
# Override the workflow storage root directory
|
||||
# Default: ~/.uncaged/workflow
|
||||
WORKFLOW_STORAGE_ROOT=
|
||||
|
||||
# Gateway secret for the serve command
|
||||
WORKFLOW_DASHBOARD_SECRET=
|
||||
|
||||
# ── Display ──
|
||||
|
||||
# Set to any value to disable colored output
|
||||
# NO_COLOR=1
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "🔍 Running check (tsc + biome + lint-log-tags)..."
|
||||
bun run check
|
||||
|
||||
echo "🧪 Running tests..."
|
||||
bun run test
|
||||
|
||||
echo "✅ All checks passed!"
|
||||
@@ -4,3 +4,8 @@ bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
bunfig.toml
|
||||
xiaoju/
|
||||
solve-issue-entry.ts
|
||||
packages/workflow-template-develop/develop.esm.js
|
||||
|
||||
@@ -10,7 +10,7 @@ This monorepo implements a workflow engine that executes single-file ESM bundles
|
||||
|---------|-----------|
|
||||
| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. State lives in CAS (linked nodes); active threads indexed in `threads.json`; completed rows in `history/*.jsonl`. Debug logs use `.info.jsonl`. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
|
||||
@@ -30,6 +30,7 @@ workflow/
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-agent-react/ # @uncaged/workflow-agent-react
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
@@ -40,7 +41,7 @@ workflow/
|
||||
```
|
||||
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:*` protocol
|
||||
- Packages use `workspace:^` protocol (resolves to `^x.y.z` on publish)
|
||||
|
||||
## Language & Paradigm
|
||||
|
||||
@@ -245,6 +246,50 @@ bun run format # biome format --write
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
### Version Management & Publishing
|
||||
|
||||
All public `@uncaged/*` packages are published to **npmjs.org** via `@changesets/cli` with **fixed mode** (all packages share the same version number). `workflow-dashboard` is private and excluded.
|
||||
|
||||
```bash
|
||||
# 1. After making changes, add a changeset describing the change
|
||||
bun changeset
|
||||
|
||||
# 2. Before release, bump all package versions + generate CHANGELOGs
|
||||
bun version
|
||||
|
||||
# 3. Build, test, and publish to npmjs
|
||||
bun release
|
||||
```
|
||||
|
||||
- `workspace:^` dependencies resolve to `^x.y.z` on publish
|
||||
- Changesets config: `.changeset/config.json` (fixed mode, public access)
|
||||
- Each package has auto-generated `CHANGELOG.md`
|
||||
|
||||
### Consuming @uncaged/* Packages
|
||||
|
||||
External workflow repos just `bun install` — packages come from npmjs like any other dependency. No special registry config needed.
|
||||
|
||||
### End-to-end: Monorepo → Registry → Workspace → Bundle
|
||||
|
||||
```
|
||||
workflow/ (monorepo) — engine, runtime, templates, agents
|
||||
│ bun release — build + test + changeset publish
|
||||
▼
|
||||
npmjs.org — @uncaged/* scoped packages (public)
|
||||
│ bun install
|
||||
▼
|
||||
my-workflows/ (workspace) — normal package.json
|
||||
│ bun run build:develop — bun build → single .esm.js
|
||||
▼
|
||||
uncaged-workflow workflow add — register bundle locally
|
||||
uncaged-workflow run — execute workflow
|
||||
```
|
||||
|
||||
1. **Monorepo changes** → `bun changeset` (describe change) → `bun version` (bump) → `bun release` (publish)
|
||||
2. **Workspace** → `bun install` fetches latest from npmjs
|
||||
3. **Build** → produces single-file ESM bundle with `@uncaged/*` as externals
|
||||
4. **Register & Run** → `uncaged-workflow workflow add <name> <bundle>` then `uncaged-workflow run <name>`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ A workflow engine that executes single-file ESM bundles. Each workflow is a self
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
|
||||
+8
-2
@@ -1,7 +1,13 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/dist",
|
||||
"!**/node_modules",
|
||||
"!packages/workflow/workflow",
|
||||
"!xiaoju/scripts/bundle.ts"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[test]
|
||||
pathIgnorePatterns = ["dist/**"]
|
||||
@@ -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 });
|
||||
+12
-13
@@ -189,11 +189,15 @@ type WorkflowFn = (
|
||||
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
|
||||
├── bundles/
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
|
||||
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
|
||||
│ └── history/
|
||||
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
|
||||
├── logs/ # One folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW…YG.data.jsonl # Thread state
|
||||
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||
│ ├── 01KQXKW…YG.running # Present while worker executes this thread (optional)
|
||||
│ └── 01KQXKW…YG.info.jsonl # Debug log
|
||||
└── workflow.yaml # Registry
|
||||
```
|
||||
|
||||
@@ -207,18 +211,13 @@ type WorkflowFn = (
|
||||
|
||||
Managed by `@uncaged/workflow-register` (`readWorkflowRegistry`, `writeWorkflowRegistry`, …). Shape includes workflow entries and a top-level `config` section used for extract/supervisor model resolution.
|
||||
|
||||
### Thread JSONL
|
||||
### Thread storage (CAS + index)
|
||||
|
||||
**`.data.jsonl`** — Line 1: start record; following lines: role steps with CAS-backed content.
|
||||
Thread execution state is a chain of immutable CAS nodes (`StartNode`, `StateNode`, content Merkle blobs). Per bundle:
|
||||
|
||||
```jsonc
|
||||
// Start record
|
||||
{ "name": "solve-issue", "hash": "C9NMV6V2TQT81", "threadId": "01KQXKW…",
|
||||
"parameters": { "prompt": "Fix bug #3", "options": { "maxRounds": 5 } },
|
||||
"timestamp": 1714963200000 }
|
||||
// Role output (engine persists contentHash + refs; body in ~/.uncaged/workflow/cas/)
|
||||
{ "role": "planner", "contentHash": "…", "meta": { "phases": [...] }, "refs": ["…"], "timestamp": ... }
|
||||
```
|
||||
- **`threads.json`** — only in-flight threads (`head`, `start`, `updatedAt`).
|
||||
- **`history/{YYYY-MM-DD}.jsonl`** — completed threads (`threadId`, `head`, `start`, `completedAt`).
|
||||
- **CAS (`cas/`)** — payloads and refs for replay, GC, and fork sharing.
|
||||
|
||||
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# RFC: Merkle Call Stack — Cross-Thread DAG Linking
|
||||
|
||||
**Author:** 小橘 🍊(NEKO Team)
|
||||
**Date:** 2026-05-11
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
当 `workflowAsAgent` 在父 workflow 中 spawn 子 workflow 时,父子 thread 之间没有任何 Merkle 链接:
|
||||
|
||||
1. **子 thread 不知道自己从哪来** — start node 只有 prompt hash,无法追溯父 thread 的上下文(preparer 分析出的 repoPath、conventions 等)
|
||||
2. **父 thread 不知道子 thread 在哪** — developer role 的 state node 里只有 agent 返回的文本,child thread root hash 埋在字符串里,不是结构化 ref
|
||||
3. **上下文传递靠序列化到 prompt** — 父 workflow 前置 role 的产出只能通过拼字符串传给子 workflow,丢失了 Merkle DAG 的可遍历性
|
||||
|
||||
## Proposal
|
||||
|
||||
在 CAS 节点中建立父子 thread 之间的 **双向 Merkle 链接**,形成调用栈结构。
|
||||
|
||||
### 新增字段
|
||||
|
||||
#### StartNodePayload(子 → 父)
|
||||
|
||||
```typescript
|
||||
type StartNodePayload = {
|
||||
name: string;
|
||||
hash: string;
|
||||
depth: number;
|
||||
parentState: string | null; // NEW: 父 thread 调用时的 head state hash
|
||||
};
|
||||
```
|
||||
|
||||
`parentState` 指向子 workflow 被 spawn 时,父 thread 的最后一个 state node hash。这是"调用发生时的调用栈帧"。
|
||||
|
||||
#### StateNodePayload(父 → 子)
|
||||
|
||||
```typescript
|
||||
type StateNodePayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
start: string;
|
||||
content: string;
|
||||
ancestors: string[];
|
||||
compact: string | null;
|
||||
timestamp: number;
|
||||
childThread: string | null; // NEW: 子 thread 最终 state hash(执行结果)
|
||||
};
|
||||
```
|
||||
|
||||
`childThread` 指向子 thread 完成后的**最终 state hash**(不是 start)——语义上是"函数返回值",从这里沿 ancestors 可回溯子 thread 的完整执行历史。
|
||||
|
||||
### refs 同步
|
||||
|
||||
新增的 hash 也必须放进 `refs[]`:
|
||||
|
||||
- `StartNode.refs`: `[promptHash, parentState]`(parentState 非 null 时)
|
||||
- `StateNode.refs`: `[...existingRefs, childThread]`(childThread 非 null 时)
|
||||
|
||||
原因:GC 的 `findReachableHashes` 只走 `refs`,不解析 payload 字段。字段提供语义,refs 保证可达性。
|
||||
|
||||
### 具体 DAG 结构
|
||||
|
||||
以 `solve-issue`(fix #191)为例,developer role 委托给 `develop` 子 workflow:
|
||||
|
||||
```
|
||||
父 thread: solve-issue
|
||||
═══════════════════════════════════════════════════════════
|
||||
|
||||
content("fix #191")
|
||||
hash: ABCD1234
|
||||
|
||||
start(solve-issue)
|
||||
hash: START001
|
||||
payload: { name: "solve-issue", hash: BUNDLE_SI, depth: 0, parentState: null }
|
||||
refs: [ABCD1234]
|
||||
|
||||
state(preparer)
|
||||
hash: STATE_P1
|
||||
payload: { role: "preparer", meta: { repoPath: "...", ... }, childThread: null, ... }
|
||||
refs: [PREP_CONTENT]
|
||||
|
||||
state(developer) ──────── 父→子 ────────
|
||||
hash: STATE_D1 │
|
||||
payload: { role: "developer", meta: { ... }, childThread: ★CSTATE_END, ... }
|
||||
refs: [DEV_CONTENT, ★CSTATE_END] │
|
||||
│
|
||||
state(submitter) │
|
||||
hash: STATE_S1 │
|
||||
payload: { role: "submitter", ..., childThread: null } │
|
||||
│
|
||||
│
|
||||
子 thread: develop │
|
||||
═══════════════════════════════════════════════════════════ │
|
||||
│
|
||||
content("fix #191") (CAS 去重,可能同 ABCD1234) │
|
||||
hash: CPROMPT1 │
|
||||
──────── 子→父 ──────── │
|
||||
start(develop) │ │
|
||||
hash: CHILD_START │ │
|
||||
payload: { name: "develop", hash: BUNDLE_DEV, depth: 1, │
|
||||
parentState: ★STATE_P1 } │ │
|
||||
refs: [CPROMPT1, ★STATE_P1] │ │
|
||||
│ │
|
||||
state(planner) │ │
|
||||
hash: CSTATE_1 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(coder) │ │
|
||||
hash: CSTATE_2 │ │
|
||||
... │ │
|
||||
│ │
|
||||
state(reviewer) → state(tester) → state(committer) │
|
||||
│ │
|
||||
hash: ★CSTATE_END ◄─────────────────┼─────────────────────────┘
|
||||
```
|
||||
|
||||
### 遍历路径
|
||||
|
||||
**子 thread agent 获取父上下文(上行):**
|
||||
```
|
||||
当前 step → start(CHILD_START)
|
||||
→ refs[1] = STATE_P1(父 preparer 的 state)
|
||||
→ payload.meta.repoPath = "/home/.../workflow"
|
||||
→ refs → PREP_CONTENT(完整 preparer 输出)
|
||||
→ payload.start = START001(父的 start node)
|
||||
→ refs[0] = ABCD1234(原始 prompt)
|
||||
```
|
||||
|
||||
**从父 thread 追踪子 thread 执行(下行):**
|
||||
```
|
||||
STATE_D1(父 developer state)
|
||||
→ payload.childThread = CSTATE_END
|
||||
→ 子 thread 最终 state
|
||||
→ 沿 ancestors 回溯:committer → tester → reviewer → coder → planner
|
||||
→ payload.start = CHILD_START(子 thread 入口)
|
||||
```
|
||||
|
||||
**完整调用栈还原:**
|
||||
```
|
||||
任意节点 → 沿 start 找到所属 thread 的 StartNode
|
||||
→ parentState 非 null?沿 parentState 进入父 thread
|
||||
→ 递归直到 parentState = null(顶层 workflow)
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Protocol + CAS 层
|
||||
|
||||
1. `workflow-protocol/src/cas-types.ts` — `StartNodePayload` 加 `parentState: string | null`,`StateNodePayload` 加 `childThread: string | null`
|
||||
2. `workflow-cas/src/nodes.ts` — `putStartNode` 接受可选 `parentStateHash`,放入 refs;`putStateNode` 接受可选 `childThreadHash`,放入 refs
|
||||
3. `workflow-cas/src/nodes.ts` — 解析逻辑兼容新字段(缺失时视为 null)
|
||||
|
||||
### Phase 2: Engine 层
|
||||
|
||||
4. `workflow-execute/src/engine/engine.ts` — `executeThread` 接受 `parentStateHash: string | null`,传给 `putStartNode`
|
||||
5. `workflow-execute/src/workflow-as-agent.ts` — spawn 子 thread 时传入父 thread 当前 head state hash 作为 `parentStateHash`;子 thread 完成后返回最终 state hash
|
||||
6. Engine 写 developer role 的 state node 时,把子 thread 最终 hash 写入 `childThread` 字段
|
||||
|
||||
### Phase 3: Agent 可观测性
|
||||
|
||||
7. Agent prompt 构建(`buildAgentPrompt`)— 当 start node 有 `parentState` 时,提示 agent 可通过 `cas get` 遍历父上下文
|
||||
8. CLI `thread show` — 显示 parentState / childThread 链接关系
|
||||
|
||||
### Phase 4: 验证
|
||||
|
||||
9. 已有测试适配新字段(向后兼容,旧节点 parentState/childThread 为 null)
|
||||
10. 新增集成测试:workflowAsAgent 场景下验证双向链接正确写入
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 为什么 childThread 指向 end 而不是 start?
|
||||
|
||||
- 语义是"函数返回值"——父 role 执行完才产出 state,此时子 thread 已跑完
|
||||
- 从 end 沿 ancestors 可回溯到 start;反过来 start 写入时子 thread 还没跑完,无法知道 end
|
||||
|
||||
### 为什么 parentState 指向 state 而不是 start?
|
||||
|
||||
- 指向父 thread 调用点的**前一个 state**(即调用发生时的 head)
|
||||
- 这是子 workflow 能看到的父上下文的"切面"——所有已完成的前置 role 都可达
|
||||
- 如果是第一个 role 就 spawn 子 workflow(没有前置 state),parentState 指向父的 start node
|
||||
|
||||
### 为什么同时放字段和 refs?
|
||||
|
||||
- `refs[]` 服务于 GC(`findReachableHashes` 只遍历 refs)和通用 DAG 遍历
|
||||
- `payload.parentState` / `payload.childThread` 服务于语义读取(明确知道哪个 ref 是什么)
|
||||
- 不改 GC 逻辑,只加字段,GC 自然正确
|
||||
|
||||
### 向后兼容
|
||||
|
||||
- 新字段默认 `null`,旧节点解析时缺失字段视为 `null`
|
||||
- 不影响已有 thread 的遍历和 GC
|
||||
- `depth` 可通过沿 parentState 链上溯来交叉验证(数据自证)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **多子 thread** — 如果一个 role 需要 spawn 多个子 workflow(目前不存在这个场景),`childThread` 应该改成 `childThreads: string[]` 还是保持单个?
|
||||
2. **Agent prompt 注入深度** — 子 workflow 的 agent 应该自动遍历多少层父上下文?全部还是限制深度?
|
||||
3. **CLI 展示** — `thread show` 要不要递归展示整个调用栈,还是只显示直接链接?
|
||||
@@ -0,0 +1,224 @@
|
||||
# Dashboard Workflow Graph Visualization
|
||||
|
||||
**Issue**: #198
|
||||
**Status**: In Progress
|
||||
**Author**: xingyue
|
||||
|
||||
## Overview
|
||||
|
||||
在 Dashboard 的 ThreadDetail 页面中嵌入一个交互式流程图,将 workflow 的 `ModeratorTable` 可视化为有向图。用户可以一眼看到角色流转结构和当前执行进度。
|
||||
|
||||
## 数据层(✅ 已完成 — PR #201)
|
||||
|
||||
### WorkflowGraph 类型
|
||||
|
||||
`WorkflowDefinition.moderator`(函数)已替换为 `WorkflowDefinition.table`(声明式 `ModeratorTable`),`buildDescriptor` 自动从 table 提取 graph:
|
||||
|
||||
```ts
|
||||
type WorkflowGraphEdge = {
|
||||
from: string; // source role 或 "__start__"
|
||||
to: string; // target role 或 "__end__"
|
||||
condition: string; // condition.name 或 "FALLBACK"
|
||||
conditionDescription: string | null;
|
||||
};
|
||||
|
||||
type WorkflowGraph = {
|
||||
edges: readonly WorkflowGraphEdge[];
|
||||
};
|
||||
|
||||
type WorkflowDescriptor = {
|
||||
description: string;
|
||||
roles: Record<string, WorkflowRoleDescriptor>;
|
||||
graph: WorkflowGraph; // 必填,新 bundle 自动生成
|
||||
};
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
ModeratorTable (WorkflowDefinition.table)
|
||||
→ buildDescriptor() 自动提取 graph
|
||||
→ descriptor.yaml 持久化(hash.yaml)
|
||||
→ CLI serve /workflows/:name API 返回 descriptor
|
||||
→ Dashboard 前端拿到 graph
|
||||
```
|
||||
|
||||
### 剩余数据层工作
|
||||
|
||||
**serve API 需要返回 descriptor**:当前 `GET /workflows/:name` 只返回 registry entry(hash + timestamp),不含 descriptor。需要从 `bundles/{hash}.yaml` 读取 descriptor 并返回给前端。
|
||||
|
||||
方案:在 `routes-workflow.ts` 的 `GET /workflows/:name` 响应中附带 `descriptor` 字段。或者:thread-detail 发现 workflow name 后,请求 `GET /workflows/:name/descriptor` 拿到 graph。
|
||||
|
||||
## 前端渲染
|
||||
|
||||
### 库选型:React Flow + dagre
|
||||
|
||||
| 库 | 优势 | 劣势 |
|
||||
|---|---|---|
|
||||
| **React Flow** ✅ | React 原生、自定义节点/边、dagre 自动布局、~50KB gzip | 需要学 API |
|
||||
| Mermaid | 声明式简单 | 无交互、无法高亮当前步骤 |
|
||||
| D3 | 完全控制 | 太底层,手撸成本高 |
|
||||
| Cytoscape | 图论强 | React 集成差 |
|
||||
|
||||
**依赖新增**:
|
||||
|
||||
```json
|
||||
{
|
||||
"@xyflow/react": "^12",
|
||||
"@dagrejs/dagre": "^1"
|
||||
}
|
||||
```
|
||||
|
||||
### 图结构映射
|
||||
|
||||
```
|
||||
WorkflowGraph.edges → React Flow nodes + edges
|
||||
|
||||
节点(自动从 edges 推导):
|
||||
- __start__ → 圆形小节点(入口)
|
||||
- role → 圆角矩形,显示 role name + description
|
||||
- __end__ → 圆形小节点(终止)
|
||||
|
||||
边:
|
||||
- FALLBACK → 虚线(dashed),无 label
|
||||
- condition → 实线,label = condition
|
||||
hover tooltip = conditionDescription
|
||||
```
|
||||
|
||||
### 布局
|
||||
|
||||
使用 dagre 自动计算 TB(top-to-bottom)方向布局:
|
||||
|
||||
```ts
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
|
||||
function layoutGraph(nodes, edges) {
|
||||
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 60, ranksep: 80 });
|
||||
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
}
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
Dagre.layout(g);
|
||||
|
||||
return nodes.map((node) => {
|
||||
const pos = g.node(node.id);
|
||||
return { ...node, position: { x: pos.x - 90, y: pos.y - 30 } };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 运行时高亮
|
||||
|
||||
ThreadDetail 已有 `records: ThreadRecord[]`,其中 `RoleRecord.role` 就是当前/历史执行的 role。
|
||||
|
||||
高亮逻辑:
|
||||
|
||||
```ts
|
||||
function getNodeStates(records: ThreadRecord[]): Map<string, "completed" | "active"> {
|
||||
const states = new Map<string, "completed" | "active">();
|
||||
const roleRecords = records.filter((r) => r.type === "role");
|
||||
|
||||
for (let i = 0; i < roleRecords.length; i++) {
|
||||
const role = roleRecords[i].role;
|
||||
states.set(role, i === roleRecords.length - 1 ? "active" : "completed");
|
||||
}
|
||||
|
||||
// 如果有 workflow-result,最后一个 role 也是 completed
|
||||
if (records.some((r) => r.type === "workflow-result")) {
|
||||
for (const [k] of states) {
|
||||
states.set(k, "completed");
|
||||
}
|
||||
states.set("__end__", "completed");
|
||||
}
|
||||
|
||||
states.set("__start__", "completed");
|
||||
return states;
|
||||
}
|
||||
```
|
||||
|
||||
节点样式:
|
||||
|
||||
| 状态 | 样式 |
|
||||
|------|------|
|
||||
| default | `border: var(--color-border)`, 暗色背景 |
|
||||
| completed | `border: var(--color-success)`, 绿色边框 + ✓ 图标 |
|
||||
| active | `border: var(--color-accent)`, 蓝色边框 + 脉冲动画 |
|
||||
|
||||
边高亮:当 source 和 target 都至少 completed 时,边变绿。
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
workflow-dashboard/src/
|
||||
components/
|
||||
workflow-graph/
|
||||
types.ts — NodeState 等前端类型
|
||||
index.ts — export { WorkflowGraph }
|
||||
workflow-graph.tsx — 主组件,React Flow canvas
|
||||
role-node.tsx — 自定义 role 节点
|
||||
terminal-node.tsx — START/END 圆形节点
|
||||
condition-edge.tsx — 自定义边(虚线/实线 + label)
|
||||
use-layout.ts — dagre 布局 hook
|
||||
```
|
||||
|
||||
### 集成到 ThreadDetail
|
||||
|
||||
在 ThreadDetail 中,records 列表上方插入可折叠的图面板:
|
||||
|
||||
```tsx
|
||||
// thread-detail.tsx
|
||||
{graph && (
|
||||
<div className="mb-4 border rounded-lg overflow-hidden" style={{ height: 300 }}>
|
||||
<WorkflowGraph graph={graph} nodeStates={getNodeStates(records)} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
图高度固定 300px,React Flow 支持 pan + zoom,不影响下方 records 滚动。
|
||||
|
||||
## 实施计划
|
||||
|
||||
### ~~Phase 0: 数据层~~ ✅ Done (PR #201)
|
||||
|
||||
- [x] `WorkflowDefinition.moderator` → `table` (ModeratorTable)
|
||||
- [x] `WorkflowDescriptor` 新增 `graph: WorkflowGraph`
|
||||
- [x] `buildDescriptor` 自动提取 graph
|
||||
- [x] `validateWorkflowDescriptor` 校验 graph
|
||||
|
||||
### Phase 1: API + 静态图渲染
|
||||
|
||||
1. serve API:`GET /workflows/:name` 返回 descriptor(含 graph),或新增 `GET /workflows/:name/descriptor`
|
||||
2. Dashboard `api.ts` 新增 `getWorkflowDescriptor(agent, name)` 函数
|
||||
3. 安装 `@xyflow/react` + `@dagrejs/dagre`
|
||||
4. 实现 `workflow-graph/` 组件集
|
||||
5. ThreadDetail 中集成:从 thread-start record 拿 workflow name → 请求 descriptor → 渲染图
|
||||
|
||||
**产出**:打开 ThreadDetail 看到 workflow 流程图,无高亮。
|
||||
|
||||
### Phase 2: 运行时高亮
|
||||
|
||||
1. ThreadDetail 根据 records 计算 nodeStates
|
||||
2. 节点/边样式响应状态变化
|
||||
3. SSE live 模式下实时更新高亮
|
||||
|
||||
**产出**:正在运行的 thread 能看到当前执行到哪个 role。
|
||||
|
||||
### Phase 3: 交互增强
|
||||
|
||||
1. 点击节点滚动到对应 role 的 RecordCard
|
||||
2. 边 hover 显示 conditionDescription tooltip
|
||||
3. 节点 hover 显示 role description + schema summary
|
||||
|
||||
**产出**:图和记录列表联动。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **自循环边**:如 `coder → coder (FALLBACK)`,React Flow 支持自循环,dagre 需要特殊处理(self-edge 用 loop 路径)
|
||||
- **大图性能**:dagre 在 <50 节点时性能无忧,workflow 通常 <10 个 role
|
||||
- **暗色主题**:Dashboard 已使用 CSS variables,节点/边样式复用现有色板
|
||||
- **不提交 pnpm-lock.yaml**
|
||||
@@ -0,0 +1,191 @@
|
||||
# workflow-agent-react — ReAct Agent Package
|
||||
|
||||
**Status**: RFC v3
|
||||
**Author**: 小橘 🍊
|
||||
|
||||
## Problem
|
||||
|
||||
现有的 agent 包都依赖外部 CLI 进程:
|
||||
|
||||
| Package | 机制 | 能力 |
|
||||
|---------|------|------|
|
||||
| `workflow-agent-hermes` | spawn `hermes chat` | 完整工具链(文件、终端、浏览器…) |
|
||||
| `workflow-agent-cursor` | spawn `cursor-agent` | IDE 级别代码编辑 |
|
||||
| `workflow-agent-llm` | 单轮 chat completion | 纯文本,无工具 |
|
||||
|
||||
缺少一个 **内置 ReAct agent**:用 LLM + tool calling 循环执行任务,不依赖外部 CLI,工具集由调用方注入。
|
||||
|
||||
## 核心设计变更:AdapterFn 替代 AgentFn
|
||||
|
||||
### 现状的问题
|
||||
|
||||
当前 `AgentFn` 返回 `string`,engine 再用额外一轮 LLM 调用 extract meta:
|
||||
|
||||
```
|
||||
Agent(ctx) → string → Extract(string, schema) → meta // 浪费一轮 LLM
|
||||
```
|
||||
|
||||
### 新抽象:AdapterFn
|
||||
|
||||
```typescript
|
||||
type RoleFn<T> = (ctx: ThreadContext) => Promise<T>;
|
||||
|
||||
type AdapterFn = <T>(prompt: string, schema: z.ZodType<T>) => RoleFn<T>;
|
||||
```
|
||||
|
||||
- **`prompt`** — role 的 system prompt,描述角色职责和输出要求
|
||||
- **`schema`** — role 的 meta schema,定义输出格式
|
||||
- **`ThreadContext`** — threadId, depth, bundleHash, start, steps
|
||||
|
||||
prompt 和 schema 是一对:prompt 说"你要输出什么",schema 定义"输出的格式"。它们属于 role definition,由 `createWorkflow` 在每个 role 执行时传给 adapter。
|
||||
|
||||
### AgentContext 不再需要
|
||||
|
||||
`AgentContext` 在 `ThreadContext` 上扩展了 `currentRole: { name, systemPrompt }`。prompt 现在直接传给 adapter,`AgentContext` 可以删除。
|
||||
|
||||
### createWorkflow 签名变更
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides: Partial<Record<string, AgentFn>> | null;
|
||||
};
|
||||
|
||||
// After
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
```
|
||||
|
||||
engine 对每个 role 的执行逻辑:
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
const result = await agent({ ...threadCtx, currentRole: { name, systemPrompt } });
|
||||
const meta = await extract(result, role.metaSchema, provider); // 额外一轮 LLM
|
||||
|
||||
// After
|
||||
const roleFn = adapter(role.systemPrompt, role.metaSchema);
|
||||
const meta = await roleFn(threadCtx); // 直接拿到类型安全的 T
|
||||
```
|
||||
|
||||
## `createReactAdapter` — 复用 workflow-reactor
|
||||
|
||||
AdapterFn 的终止条件是"拿到符合 schema 的 T"——和 `workflow-reactor` 的 `ThreadReactorFn` 完全一致。因此 react adapter 是对 reactor 的**薄包装**,不需要自己实现 ReAct 循环。
|
||||
|
||||
```typescript
|
||||
import { createLlmFn, createThreadReactor } from "@uncaged/workflow-reactor";
|
||||
import type { ThreadContext, LlmProvider } from "@uncaged/workflow-protocol";
|
||||
import type { ToolDefinition } from "@uncaged/workflow-reactor";
|
||||
|
||||
type ReactToolHandler = (name: string, args: string) => Promise<string>;
|
||||
|
||||
type ReactAdapterConfig = {
|
||||
provider: LlmProvider;
|
||||
tools: readonly ToolDefinition[];
|
||||
toolHandler: ReactToolHandler;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
function createReactAdapter(config: ReactAdapterConfig): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>) => {
|
||||
const reactor = createThreadReactor<ThreadContext>({
|
||||
llm: createLlmFn(config.provider),
|
||||
staticTools: config.tools,
|
||||
structuredToolFromSchema: (s) => buildStructuredTool(s),
|
||||
systemPromptForStructuredTool: () => prompt,
|
||||
toolHandler: (call, ctx) =>
|
||||
config.toolHandler(call.function.name, call.function.arguments),
|
||||
maxRounds: config.maxRounds,
|
||||
});
|
||||
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const input = buildThreadInput(ctx);
|
||||
const result = await reactor({ thread: ctx, input, schema });
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
return result.value;
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
整个包就是:**一个工厂函数 + 类型定义 + thread 输入构造**。
|
||||
|
||||
## `agentToAdapter` — 向后兼容
|
||||
|
||||
把现有 `AgentFn`(hermes/cursor)包装成 `AdapterFn`:
|
||||
|
||||
```typescript
|
||||
function agentToAdapter(agent: AgentFn, extractProvider: LlmProvider): AdapterFn {
|
||||
return <T>(prompt: string, schema: z.ZodType<T>): RoleFn<T> => {
|
||||
return async (ctx: ThreadContext): Promise<T> => {
|
||||
const agentCtx = { ...ctx, currentRole: { name: "agent", systemPrompt: prompt } };
|
||||
const result = await agent(agentCtx);
|
||||
const output = typeof result === "string" ? result : result.output;
|
||||
return extract(output, schema, extractProvider);
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
hermes/cursor agent 内部不改,bundle-entry 层多包一层即可。
|
||||
|
||||
## 包结构
|
||||
|
||||
```
|
||||
packages/workflow-agent-react/
|
||||
src/
|
||||
types.ts # ReactAdapterConfig, ReactToolHandler
|
||||
create-react-adapter.ts # AdapterFn 工厂(包装 reactor)
|
||||
thread-input.ts # ThreadContext → user message string
|
||||
index.ts
|
||||
__tests__/
|
||||
create-react-adapter.test.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
依赖:
|
||||
- `@uncaged/workflow-protocol` — `ThreadContext`, `LlmProvider`
|
||||
- `@uncaged/workflow-reactor` — `createLlmFn`, `createThreadReactor`, types
|
||||
|
||||
## 影响范围
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
| 改动 | 影响 |
|
||||
|------|------|
|
||||
| `AgentBinding` → `AdapterBinding` | `createWorkflow` 调用方(所有 bundle-entry) |
|
||||
| `AgentContext` 删除 | `buildAgentPrompt`(util-agent)改为接收 `ThreadContext` |
|
||||
| extract 从 engine 下沉到 adapter | `workflow-execute` 简化 |
|
||||
|
||||
### 需修改的包
|
||||
|
||||
1. `workflow-protocol` — 删除 `AgentContext`/`AgentFn`/`AgentFnResult`/`AgentBinding`,新增 `AdapterFn`/`RoleFn`/`AdapterBinding`
|
||||
2. `workflow-runtime` — 更新 re-export
|
||||
3. `workflow-execute` — engine 调用 `adapter(prompt, schema)` 替代 `agent(ctx) + extract`
|
||||
4. `workflow-util-agent` — `buildAgentPrompt` → `buildThreadInput`,接收 `ThreadContext`
|
||||
5. 所有 bundle-entry — `agent:` → `adapter:`
|
||||
|
||||
### 不受影响
|
||||
|
||||
- `workflow-cas` / `workflow-register` / `workflow-reactor` / `workflow-dashboard`
|
||||
- `workflow-agent-hermes` / `workflow-agent-cursor`(内部不改,外部用 `agentToAdapter` 包装)
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Phase 1**: protocol 类型 + `createWorkflow` 签名变更 + `agentToAdapter`
|
||||
2. **Phase 2**: `workflow-agent-react` 包(包装 reactor)
|
||||
3. **Phase 3**: 工具集实现(read/write/patch/shell) + smoke test 闭环
|
||||
|
||||
## 工具集(后续讨论)
|
||||
|
||||
| 工具 | 说明 | 优先级 |
|
||||
|------|------|--------|
|
||||
| `read_file` | 读文件 | P0 |
|
||||
| `write_file` | 写文件 | P0 |
|
||||
| `patch_file` | find-and-replace 编辑 | P0 |
|
||||
| `shell_exec` | 执行 shell 命令 | P0 |
|
||||
| `search_files` | grep / find | P1 |
|
||||
| `list_files` | ls | P1 |
|
||||
+11
-2
@@ -4,15 +4,24 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"overrides": {
|
||||
"@uncaged/json-cas": "file:../json-cas/packages/json-cas",
|
||||
"@uncaged/json-cas-workflow": "file:../json-cas/packages/json-cas-workflow"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"check": "bunx tsc --build && biome check . && bash scripts/lint-log-tags.sh",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
"test": "bun run --filter '*' test"
|
||||
"test": "bun run --filter '*' test",
|
||||
"changeset": "bunx changeset",
|
||||
"version": "bunx changeset version",
|
||||
"release": "bun run build && bun test && npx changeset publish --no-git-tag"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/xxhashjs": "^0.2.4",
|
||||
"bun-types": "^1.3.13"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.4
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.4
|
||||
- @uncaged/workflow-register@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.3
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.3
|
||||
- @uncaged/workflow-register@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.2
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.2
|
||||
- @uncaged/workflow-register@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.1
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-register@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-execute@0.5.0-alpha.0
|
||||
- @uncaged/workflow-register@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
- @uncaged/workflow-gateway@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-cas@0.4.5
|
||||
- @uncaged/workflow-execute@0.4.5
|
||||
- @uncaged/workflow-gateway@0.4.5
|
||||
- @uncaged/workflow-register@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-cas@0.4.4
|
||||
- @uncaged/workflow-execute@0.4.4
|
||||
- @uncaged/workflow-gateway@0.4.4
|
||||
- @uncaged/workflow-register@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.3
|
||||
- @uncaged/workflow-execute@0.4.3
|
||||
- @uncaged/workflow-gateway@0.4.3
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-register@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.2
|
||||
- @uncaged/workflow-execute@0.4.2
|
||||
- @uncaged/workflow-gateway@0.4.2
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-register@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-cas@0.4.0
|
||||
- @uncaged/workflow-execute@0.4.0
|
||||
- @uncaged/workflow-gateway@0.4.0
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-register@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
} from "../src/commands/workflow/index.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} };
|
||||
`;
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {}, graph: { edges: [] } };
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
@@ -52,12 +49,12 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put(input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
@@ -153,11 +150,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
schema: { type: "object", properties: { greeting: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
const h = await cas.put( input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
@@ -196,9 +193,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -227,9 +224,9 @@ export const run = async function* (input, options) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -260,9 +257,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -283,16 +280,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -325,16 +322,16 @@ export const run = async function* (input, options) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
const h = await cas.put( "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
const h = await cas.put( "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
@@ -377,9 +374,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -390,9 +387,9 @@ export const run = async function* (input, options) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
@@ -445,9 +442,9 @@ export const run = async function* (input, options) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
@@ -462,9 +459,9 @@ export const run = async function* (input, options) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
`${fixtureDescriptor}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
const h = await cas.put( "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
|
||||
+4
-4
@@ -2,14 +2,14 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
import { createApp } from "../src/commands/connect/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
const app = createApp(storageRoot, null);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
@@ -115,7 +115,7 @@ describe("serve error handling", () => {
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
@@ -128,7 +128,7 @@ describe("serve error handling", () => {
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent", null);
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
@@ -1,67 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
/** Three-role workflow that respects `input.steps` for fork/resume. */
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
const threeRoleBundleSource = `export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
coder: { description: "coder", schema: {} },
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
const h = await cas.put( "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
const h = await cas.put( "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, body);
|
||||
const h = await cas.put( body);
|
||||
yield { role: "reviewer", contentHash: h, meta: { k: "reviewer" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||
try {
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilMinDataLines(dataPath: string, minLines: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if ((await countDataJsonlLines(dataPath)) >= minLines) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
@@ -71,6 +53,41 @@ async function waitUntilRunningAbsent(runningPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilThreadCompletes(storageRoot: string, threadId: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 120; attempt++) {
|
||||
const row = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (row?.source === "history") {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function listMeaningfulRoleContents(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Array<{ role: string; content: string }>> {
|
||||
const row = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (row === null) {
|
||||
return [];
|
||||
}
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, row.head);
|
||||
const chronological = [...frames].reverse();
|
||||
const out: Array<{ role: string; content: string }> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
const content = await getContentMerklePayload(cas, fr.payload.content);
|
||||
out.push({
|
||||
role: fr.payload.role,
|
||||
content: content ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("cli fork", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -110,10 +127,12 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 5);
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const histBefore = await resolveThreadRecord(storageRoot, sourceId);
|
||||
expect(histBefore?.source).toBe("history");
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, "planner");
|
||||
expect(forked.ok).toBe(true);
|
||||
@@ -121,25 +140,18 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const newId = forked.value.threadId;
|
||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||
await waitUntilRunningAbsent(newRunning);
|
||||
await waitUntilMinDataLines(newData, 5);
|
||||
await waitUntilThreadCompletes(storageRoot, newId);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(5);
|
||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(start.threadId).toBe(newId);
|
||||
expect(start.forkFrom).toEqual({ threadId: sourceId });
|
||||
const forkHist = await resolveThreadRecord(storageRoot, newId);
|
||||
expect(forkHist?.source).toBe("history");
|
||||
expect(forkHist?.start).toBe(histBefore?.start);
|
||||
|
||||
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||
expect(lastRoleLine.role).toBe("reviewer");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-1");
|
||||
const steps = await listMeaningfulRoleContents(storageRoot, newId);
|
||||
const tail = steps[steps.length - 1];
|
||||
expect(tail?.role).toBe("reviewer");
|
||||
expect(tail?.content).toBe("rev-1");
|
||||
});
|
||||
|
||||
test("fork without --from-role retries last role", async () => {
|
||||
@@ -161,10 +173,8 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 5);
|
||||
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${sourceId}.running`));
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||
expect(forked.ok).toBe(true);
|
||||
@@ -172,26 +182,17 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const newId = forked.value.threadId;
|
||||
const newData = join(storageRoot, "logs", hash, `${newId}.data.jsonl`);
|
||||
const newRunning = join(storageRoot, "logs", hash, `${newId}.running`);
|
||||
await waitUntilRunningAbsent(newRunning);
|
||||
await waitUntilMinDataLines(newData, 5);
|
||||
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${newId}.running`));
|
||||
await waitUntilThreadCompletes(storageRoot, newId);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(5);
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
expect(await getContentMerklePayload(cas, String(replayCoder.contentHash))).toBe("c1");
|
||||
|
||||
const lastRoleLine = JSON.parse(lines[lines.length - 2] ?? "{}") as Record<string, unknown>;
|
||||
expect(lastRoleLine.role).toBe("reviewer");
|
||||
expect(await getContentMerklePayload(cas, String(lastRoleLine.contentHash))).toBe("rev-2");
|
||||
const steps = await listMeaningfulRoleContents(storageRoot, newId);
|
||||
expect(steps.length).toBeGreaterThanOrEqual(3);
|
||||
const coderReplay = steps[steps.length - 2];
|
||||
expect(coderReplay?.role).toBe("coder");
|
||||
expect(coderReplay?.content).toBe("c1");
|
||||
const tail = steps[steps.length - 1];
|
||||
expect(tail?.role).toBe("reviewer");
|
||||
expect(tail?.content).toBe("rev-2");
|
||||
});
|
||||
|
||||
test("fork rejects unknown role with available names", async () => {
|
||||
@@ -212,10 +213,10 @@ describe("cli fork", () => {
|
||||
return;
|
||||
}
|
||||
const sourceId = ran.value.threadId;
|
||||
const sourceData = join(storageRoot, "logs", added.value.hash, `${sourceId}.data.jsonl`);
|
||||
const sourceRunning = join(storageRoot, "logs", added.value.hash, `${sourceId}.running`);
|
||||
await waitUntilRunningAbsent(sourceRunning);
|
||||
await waitUntilMinDataLines(sourceData, 5);
|
||||
await waitUntilRunningAbsent(
|
||||
join(storageRoot, "logs", added.value.hash, `${sourceId}.running`),
|
||||
);
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const bad = await cmdFork(storageRoot, sourceId, "ghost-role");
|
||||
expect(bad.ok).toBe(false);
|
||||
|
||||
@@ -1,45 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { createCasStore, putStartNode } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas, getBundleDir, upsertThreadEntry } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdThreadRemove } from "../src/commands/thread/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
async function writeDemoDataJsonl(params: {
|
||||
path: string;
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
cas: ReturnType<typeof createCasStore>;
|
||||
activeHash: string;
|
||||
}): Promise<void> {
|
||||
const bodyHash = await putContentMerkleNode(params.cas, "p");
|
||||
const text = [
|
||||
JSON.stringify({
|
||||
name: "demo",
|
||||
hash: params.bundleHash,
|
||||
threadId: params.threadId,
|
||||
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
||||
timestamp: 100,
|
||||
}),
|
||||
JSON.stringify({
|
||||
role: "planner",
|
||||
contentHash: bodyHash,
|
||||
meta: {},
|
||||
refs: [params.activeHash, bodyHash],
|
||||
timestamp: 101,
|
||||
}),
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(params.path, text, "utf8");
|
||||
}
|
||||
|
||||
describe("gc cli and garbageCollectCas", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -59,22 +31,30 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("garbageCollectCas keeps CAS entries referenced by thread refs", async () => {
|
||||
test("garbageCollectCas keeps CAS entries reachable from threads.json roots", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01AAA1111111111111111111";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("active-blob");
|
||||
const orphanHash = await cas.put("orphan-blob");
|
||||
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
activeHash,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
@@ -82,12 +62,12 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(1);
|
||||
expect(gc.value.activeRefs).toBe(2);
|
||||
expect(gc.value.scannedThreads).toBe(2);
|
||||
expect(gc.value.deletedEntries).toBe(1);
|
||||
expect(gc.value.deletedHashes).toEqual([orphanHash]);
|
||||
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${startHash}.txt`))).toBe(true);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`))).toBe(false);
|
||||
});
|
||||
|
||||
@@ -110,19 +90,27 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
test("cli gc prints stats", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01BBB2222222222222222222";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("keep-me");
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
await cas.put("drop-me");
|
||||
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
cas,
|
||||
activeHash,
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
@@ -131,23 +119,32 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(proc.status).toBe(0);
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 2 active refs, deleted 1 entries");
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 2 threads, 2 active refs, deleted 1 entries");
|
||||
});
|
||||
|
||||
test("thread rm triggers gc so unreferenced CAS is removed", async () => {
|
||||
const bundleHash = "C9NMV6V2TQT81";
|
||||
const threadId = "01CCC3333333333333333333";
|
||||
const logsDir = join(storageRoot, "logs", bundleHash);
|
||||
await mkdir(logsDir, { recursive: true });
|
||||
const bundleDir = getBundleDir(storageRoot, bundleHash);
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const activeHash = await cas.put("pinned-by-ref");
|
||||
await writeDemoDataJsonl({
|
||||
path: join(logsDir, `${threadId}.data.jsonl`),
|
||||
threadId,
|
||||
bundleHash,
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
activeHash,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
depth: 0,
|
||||
parentState: null,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const orphanHash = await cas.put("orphan-after-rm");
|
||||
@@ -157,6 +154,6 @@ describe("gc cli and garbageCollectCas", () => {
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
expect(await pathExists(orphanPath)).toBe(false);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${activeHash}.txt`))).toBe(false);
|
||||
expect(await pathExists(join(getGlobalCasDir(storageRoot), `${promptHash}.txt`))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,11 @@ describe("--help flag on groups", () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("setup --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["setup", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
@@ -90,6 +95,8 @@ describe("formatCliUsage", () => {
|
||||
expect(u).toContain("Thread execution:");
|
||||
expect(u).toContain("Content-addressable storage:");
|
||||
expect(u).toContain("Development:");
|
||||
expect(u).toContain("Configuration:");
|
||||
expect(u).toContain("setup [--provider <name>]");
|
||||
expect(u).toContain("Shortcuts:");
|
||||
expect(u).toContain("Reference:");
|
||||
expect(u).toContain("skill [topic]");
|
||||
@@ -128,6 +135,7 @@ describe("formatSkillTopic('cli')", () => {
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### setup");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("init template", () => {
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
expect(moder).toContain("ModeratorTable");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
|
||||
@@ -38,8 +38,16 @@ describe("init workspace", () => {
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
scripts: { bundle: string };
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
expect(rootPkg.scripts.bundle).toBe("bun run scripts/bundle.ts");
|
||||
|
||||
expect(await pathExists(join(root, "scripts", "bundle.ts"))).toBe(true);
|
||||
const bundleSrc = await readFile(join(root, "scripts", "bundle.ts"), "utf8");
|
||||
expect(bundleSrc).toContain("Bun.build");
|
||||
expect(bundleSrc).toContain("-entry.ts");
|
||||
expect(bundleSrc).toContain("distDir");
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
@@ -82,8 +90,8 @@ describe("init workspace", () => {
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"AgentFn",
|
||||
"ModeratorTable",
|
||||
"AdapterFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
@@ -117,9 +125,6 @@ describe("init workspace", () => {
|
||||
});
|
||||
|
||||
test("errors on invalid workspace name", async () => {
|
||||
const slash = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(slash.ok).toBe(false);
|
||||
|
||||
const dots = await cmdInitWorkspace(parent, "..");
|
||||
expect(dots.ok).toBe(false);
|
||||
|
||||
@@ -127,6 +132,14 @@ describe("init workspace", () => {
|
||||
expect(empty.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts nested path as workspace name", async () => {
|
||||
const nested = await cmdInitWorkspace(parent, "a/b");
|
||||
expect(nested.ok).toBe(true);
|
||||
if (nested.ok) {
|
||||
expect(nested.value.rootPath).toContain("a/b");
|
||||
}
|
||||
});
|
||||
|
||||
test("usage lists init subcommands", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("init workspace <name>");
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import {
|
||||
formatLiveDebugLine,
|
||||
formatLiveTimeLabel,
|
||||
@@ -18,11 +15,6 @@ import {
|
||||
import { parseLiveArgv } from "../src/live-argv.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
const fixtureRoot = fileURLToPath(new URL("./fixtures/live", import.meta.url));
|
||||
|
||||
/** Bodies for Merkle content nodes; hashes must match `.data.jsonl` fixtures. */
|
||||
const LIVE_FIXTURE_PLANNER_BODY =
|
||||
"alpha\nbeta\ngamma\nLINE4\nLINE5\nLINE6\nLINE7\nLINE8\nLINE9\nLINE10\nLINE11";
|
||||
|
||||
describe("live helpers", () => {
|
||||
test("formatLiveTimeLabel pads HH:MM:SS", () => {
|
||||
@@ -86,28 +78,6 @@ describe("live CLI", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await mkdir(join(storageRoot, "logs", "C9NMV6V2TQT81"), { recursive: true });
|
||||
await cp(
|
||||
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
|
||||
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.data.jsonl"),
|
||||
);
|
||||
await cp(
|
||||
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
|
||||
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVECMPLT01DDDDDDDDDDDDG.info.jsonl"),
|
||||
);
|
||||
await cp(
|
||||
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
|
||||
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl"),
|
||||
);
|
||||
await cp(
|
||||
join(fixtureRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
|
||||
join(storageRoot, "logs", "C9NMV6V2TQT81", "01LIVEOLDER01DDDDDDDDDDDDG.data.jsonl"),
|
||||
);
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
await putContentMerkleNode(cas, LIVE_FIXTURE_PLANNER_BODY);
|
||||
await putContentMerkleNode(cas, "patch");
|
||||
await putContentMerkleNode(cas, "still running");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -119,170 +89,6 @@ describe("live CLI", () => {
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("prints role steps and summary for a completed thread", async () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG"], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
let buf = "";
|
||||
proc.stdout?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.stderr?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`exit ${code}: ${buf}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(stdout).toContain("planner");
|
||||
expect(stdout).toContain("coder");
|
||||
expect(stdout).toContain("meta:");
|
||||
expect(stdout).toContain('"phase":"plan"');
|
||||
expect(stdout).toContain("LINE10");
|
||||
expect(stdout).not.toContain("LINE11");
|
||||
expect(stdout).toContain("more line");
|
||||
expect(stdout).toContain("completed: returnCode=0");
|
||||
expect(stdout).toContain("fixture completed");
|
||||
});
|
||||
|
||||
test("--latest tails the newest thread by start timestamp", async () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawn(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
let buf = "";
|
||||
proc.stdout?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.stderr?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`exit ${code}: ${buf}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(stdout).toContain("fixture completed");
|
||||
expect(stdout).not.toContain("older thread");
|
||||
});
|
||||
|
||||
test("--debug prints .info.jsonl records after data output", async () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--debug"],
|
||||
{
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
let buf = "";
|
||||
proc.stdout?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.stderr?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`exit ${code}: ${buf}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(stdout).toContain("[DEBUGTAG1]");
|
||||
expect(stdout).toContain("bundle loaded");
|
||||
expect(stdout).toContain("[DEBUGTAG2]");
|
||||
expect(stdout).toContain("multi line");
|
||||
});
|
||||
|
||||
test("--role filters out non-matching roles", async () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[cliEntryPath, "live", "01LIVECMPLT01DDDDDDDDDDDDG", "--role", "planner"],
|
||||
{
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
let buf = "";
|
||||
proc.stdout?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.stderr?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`exit ${code}: ${buf}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(stdout).toContain("planner");
|
||||
expect(stdout).not.toContain("patch");
|
||||
expect(stdout).toContain("completed: returnCode=0");
|
||||
});
|
||||
|
||||
test("--latest --debug --role combine", async () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[cliEntryPath, "live", "--latest", "--debug", "--role", "planner"],
|
||||
{
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
let buf = "";
|
||||
proc.stdout?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.stderr?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`exit ${code}: ${buf}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(stdout).toContain("[DEBUGTAG1]");
|
||||
expect(stdout).toContain("planner");
|
||||
expect(stdout).not.toContain("patch");
|
||||
expect(stdout).toContain("fixture completed");
|
||||
});
|
||||
|
||||
test("unknown thread id exits 1", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
|
||||
@@ -292,51 +98,6 @@ describe("live CLI", () => {
|
||||
expect(r.status).toBe(1);
|
||||
expect(String(r.stderr ?? "")).toContain("thread not found");
|
||||
});
|
||||
|
||||
test("follows file until WorkflowResult is appended", async () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const dataPath = join(
|
||||
storageRoot,
|
||||
"logs",
|
||||
"C9NMV6V2TQT81",
|
||||
"01LIVEINFLY01DDDDDDDDDDDDG.data.jsonl",
|
||||
);
|
||||
|
||||
const proc = spawn(process.execPath, [cliEntryPath, "live", "01LIVEINFLY01DDDDDDDDDDDDG"], {
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
const prior = await readFile(dataPath, "utf8");
|
||||
await writeFile(
|
||||
dataPath,
|
||||
`${prior.replace(/\s*$/, "")}\n${JSON.stringify({ returnCode: 0, summary: "caught up" })}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
let buf = "";
|
||||
proc.stdout?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.stderr?.on("data", (c: Buffer) => {
|
||||
buf += c.toString("utf8");
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve(buf);
|
||||
} else {
|
||||
reject(new Error(`exit ${code}: ${buf}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
expect(stdout).toContain("planner");
|
||||
expect(stdout).toContain("completed: returnCode=0");
|
||||
expect(stdout).toContain("caught up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live --latest with empty storage", () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdSetup } from "../src/commands/setup/index.js";
|
||||
|
||||
describe("setup command (CLI mode)", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-setup-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await mkdir(storageRoot, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(storageRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("writes workflow.yaml with provider, models.default, and depth defaults", async () => {
|
||||
const r = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config).not.toBeNull();
|
||||
if (reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope).toEqual({
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-test123",
|
||||
});
|
||||
expect(reg.value.config.models.default).toBe("dashscope/qwen-plus");
|
||||
expect(reg.value.config.maxDepth).toBe(3);
|
||||
expect(reg.value.config.supervisorInterval).toBe(3);
|
||||
|
||||
const raw = await readFile(join(storageRoot, "workflow.yaml"), "utf8");
|
||||
expect(raw).toContain("dashscope");
|
||||
expect(raw).toContain("qwen-plus");
|
||||
});
|
||||
|
||||
test("idempotent: second run updates apiKey and preserves workflows", async () => {
|
||||
const initialYaml = `config:
|
||||
maxDepth: 7
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: sk-old
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
workflows:
|
||||
keep-me:
|
||||
hash: "0000000000000"
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), initialYaml, "utf8");
|
||||
|
||||
const r2 = await cmdSetup(storageRoot, {
|
||||
provider: "dashscope",
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "sk-newkey",
|
||||
defaultModel: "dashscope/qwen-plus",
|
||||
initWorkspaceName: null,
|
||||
});
|
||||
expect(r2.ok).toBe(true);
|
||||
if (!r2.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.dashscope.apiKey).toBe("sk-newkey");
|
||||
expect(reg.value.config.maxDepth).toBe(7);
|
||||
expect(reg.value.config.supervisorInterval).toBe(2);
|
||||
expect(reg.value.workflows["keep-me"]).toBeDefined();
|
||||
if (reg.value.workflows["keep-me"] === undefined) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.workflows["keep-me"].hash).toBe("0000000000000");
|
||||
});
|
||||
|
||||
test("runCli setup dispatches with flags and exits 0", async () => {
|
||||
const code = await runCli(storageRoot, [
|
||||
"setup",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--base-url",
|
||||
"https://api.openai.com/v1",
|
||||
"--api-key",
|
||||
"sk-test",
|
||||
"--default-model",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
expect(code).toBe(0);
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok || reg.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(reg.value.config.providers.openai.apiKey).toBe("sk-test");
|
||||
expect(reg.value.config.models.default).toBe("openai/gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { getBundleDir, readThreadsIndex } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
@@ -18,12 +19,10 @@ import {
|
||||
} from "../src/commands/thread/index.js";
|
||||
import { cmdAdd } from "../src/commands/workflow/index.js";
|
||||
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
|
||||
import { resolveThreadRecord } from "../src/thread-scan.js";
|
||||
import { addCliArgs } from "./bundle-fixture.js";
|
||||
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
|
||||
|
||||
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
`;
|
||||
|
||||
const threadFixtureDescriptor = `export const descriptor = {
|
||||
description: "thread-cli",
|
||||
roles: {
|
||||
@@ -34,29 +33,28 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
only: { description: "only", schema: {} },
|
||||
noop: { description: "noop", schema: {} },
|
||||
},
|
||||
graph: { edges: [] },
|
||||
};
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
@@ -65,70 +63,54 @@ export const run = async function* (input, options) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
let h = await cas.put( "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
h = await cas.put( "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
let h = await cas.put( "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
h = await cas.put( "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
const h = await cas.put( "x");
|
||||
yield { role: "only", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||
try {
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
return text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "").length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilMinDataLines(
|
||||
dataPath: string,
|
||||
minLines: number,
|
||||
maxAttempts: number,
|
||||
): Promise<void> {
|
||||
async function waitUntilRunningFileAbsent(runningPath: string, maxAttempts: number): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if ((await countDataJsonlLines(dataPath)) >= minLines) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilRunningFileAbsent(runningPath: string, maxAttempts: number): Promise<void> {
|
||||
async function waitUntilPredicate(
|
||||
predicate: () => Promise<boolean>,
|
||||
maxAttempts: number,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
if (!(await pathExists(runningPath))) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
@@ -190,6 +172,9 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(threads.value.some((l) => l.includes(threadId))).toBe(true);
|
||||
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
|
||||
const shown = await cmdThreadShow(storageRoot, threadId);
|
||||
expect(shown.ok).toBe(true);
|
||||
if (!shown.ok) {
|
||||
@@ -197,11 +182,18 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
expect(shown.value.includes('"threadId"')).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(shown.value) as Record<string, unknown>;
|
||||
expect(parsed.parentState).toBeNull();
|
||||
const parsedSteps = parsed.steps as Array<Record<string, unknown>>;
|
||||
for (const step of parsedSteps) {
|
||||
expect(step).toHaveProperty("childThread");
|
||||
expect(step.childThread).toBeNull();
|
||||
}
|
||||
|
||||
const removed = await cmdThreadRemove(storageRoot, threadId);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
expect(await pathExists(dataPath)).toBe(false);
|
||||
expect(await resolveThreadRecord(storageRoot, threadId)).toBeNull();
|
||||
});
|
||||
|
||||
test("thread rm runs GC and removes CAS blobs not referenced by any remaining thread", async () => {
|
||||
@@ -234,9 +226,9 @@ describe("cli thread commands", () => {
|
||||
threads = await cmdThreads(storageRoot, []);
|
||||
}
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 120);
|
||||
expect((await resolveThreadRecord(storageRoot, threadId))?.source).toBe("history");
|
||||
|
||||
const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
|
||||
expect(put.ok).toBe(true);
|
||||
@@ -317,30 +309,31 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const killBundleDir = getBundleDir(storageRoot, added.value.hash);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
await waitUntilPredicate(async () => {
|
||||
const idx = await readThreadsIndex(killBundleDir);
|
||||
const ent = idx[threadId];
|
||||
return ent !== undefined && ent.head !== ent.start;
|
||||
}, 80);
|
||||
|
||||
const killed = await cmdKill(storageRoot, threadId);
|
||||
expect(killed.ok).toBe(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
await waitUntilPredicate(async () => {
|
||||
return (await resolveThreadRecord(storageRoot, threadId))?.source === "history";
|
||||
}, 120);
|
||||
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
expect((await resolveThreadRecord(storageRoot, threadId))?.source).toBe("history");
|
||||
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
expect(await pathExists(runningPath)).toBe(false);
|
||||
});
|
||||
|
||||
test("pause stops between yields and resume completes thread", async () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const srcDir = join(storageRoot, "src");
|
||||
await mkdir(srcDir, { recursive: true });
|
||||
const bundlePath = join(srcDir, "demo.esm.js");
|
||||
await writeFile(bundlePath, pauseResumeBundleSource, "utf8");
|
||||
|
||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||
@@ -356,24 +349,33 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const bundleDir = getBundleDir(storageRoot, added.value.hash);
|
||||
|
||||
await waitUntilMinDataLines(dataPath, 2, 80);
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(2);
|
||||
await waitUntilPredicate(async () => {
|
||||
const idx = await readThreadsIndex(bundleDir);
|
||||
const ent = idx[threadId];
|
||||
return ent !== undefined && ent.head !== ent.start;
|
||||
}, 80);
|
||||
|
||||
const idxBeforePause = await readThreadsIndex(bundleDir);
|
||||
const headAtPause = idxBeforePause[threadId]?.head;
|
||||
|
||||
const paused = await cmdPause(storageRoot, threadId);
|
||||
expect(paused.ok).toBe(true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(2);
|
||||
const idxPaused = await readThreadsIndex(bundleDir);
|
||||
expect(idxPaused[threadId]?.head).toBe(headAtPause);
|
||||
|
||||
const resumed = await cmdResume(storageRoot, threadId);
|
||||
expect(resumed.ok).toBe(true);
|
||||
|
||||
await waitUntilMinDataLines(dataPath, 4, 120);
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(4);
|
||||
await waitUntilPredicate(async () => {
|
||||
const row = await resolveThreadRecord(storageRoot, threadId);
|
||||
return row?.source === "history";
|
||||
}, 120);
|
||||
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
await waitUntilRunningFileAbsent(runningPath, 100);
|
||||
expect(await pathExists(runningPath)).toBe(false);
|
||||
});
|
||||
@@ -397,8 +399,7 @@ describe("cli thread commands", () => {
|
||||
}
|
||||
|
||||
const threadId = ran.value.threadId;
|
||||
const dataPath = join(storageRoot, "logs", added.value.hash, `${threadId}.data.jsonl`);
|
||||
const runningPath = join(dirname(dataPath), `${threadId}.running`);
|
||||
const runningPath = join(storageRoot, "logs", added.value.hash, `${threadId}.running`);
|
||||
|
||||
await waitUntilRunningFileAbsent(runningPath, 100);
|
||||
expect(await pathExists(runningPath)).toBe(false);
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-protocol": "workspace:*",
|
||||
"@uncaged/workflow-util": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:*",
|
||||
"@uncaged/workflow-execute": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-gateway": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-execute": "workspace:^",
|
||||
"@uncaged/workflow-register": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"hono": "^4.12.18",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+51
@@ -0,0 +1,51 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-cas':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-cas
|
||||
'@uncaged/workflow-execute':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-execute
|
||||
'@uncaged/workflow-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-protocol
|
||||
'@uncaged/workflow-register':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-register
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util
|
||||
hono:
|
||||
specifier: ^4.12.18
|
||||
version: 4.12.18
|
||||
yaml:
|
||||
specifier: ^2.8.4
|
||||
version: 2.8.4
|
||||
|
||||
packages:
|
||||
|
||||
hono@4.12.18:
|
||||
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
yaml@2.8.4:
|
||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
hono@4.12.18: {}
|
||||
|
||||
yaml@2.8.4: {}
|
||||
@@ -3,8 +3,9 @@ import { printCliError, printCliLine } from "./cli-output.js";
|
||||
import { getCommandRegistry } from "./cli-registry.js";
|
||||
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
|
||||
import { createCasDispatcher } from "./commands/cas/index.js";
|
||||
import { dispatchConnect } from "./commands/connect/index.js";
|
||||
import { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/index.js";
|
||||
import { dispatchSetup } from "./commands/setup/index.js";
|
||||
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
|
||||
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
|
||||
@@ -66,10 +67,11 @@ const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
setup: dispatchSetup,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
connect: dispatchConnect,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
|
||||
@@ -5,6 +5,15 @@ import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
const SETUP_USAGE_COMMANDS = [
|
||||
{
|
||||
name: "",
|
||||
args: "[--provider <name>] [--base-url <url>] [--api-key <key>] [--default-model <provider/model>] [--init-workspace <name>]",
|
||||
description:
|
||||
"Configure workflow.yaml LLM providers and default model (interactive when no flags)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
@@ -39,6 +48,10 @@ export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "setup",
|
||||
commands: [...SETUP_USAGE_COMMANDS],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
setup: "Configuration:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
@@ -38,9 +39,10 @@ export function formatCliUsage(
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
prefix: `${group.name}${namePart}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
@@ -57,12 +59,12 @@ export function formatCliUsage(
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push("Gateway:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
prefix: "connect [--name NAME] [--gateway URL]",
|
||||
description: "Connect to workflow gateway via WebSocket",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
+13
-1
@@ -8,7 +8,7 @@ import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string): Hono {
|
||||
export function createApp(storageRoot: string, clientToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
@@ -37,7 +37,19 @@ export function createApp(storageRoot: string): Hono {
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Client token auth (skip healthz) ───────────────────────────────
|
||||
if (clientToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Client-Token");
|
||||
if (token !== clientToken) {
|
||||
return c.json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||
app.get("/api/healthz", (c) => c.json({ ok: true }));
|
||||
|
||||
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||
@@ -0,0 +1,111 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import { registerWithGateway, startHeartbeat, unregisterFromGateway } from "./gateway.js";
|
||||
import type { ConnectOptions } from "./types.js";
|
||||
import { startGatewayWsClient } from "./ws-client.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
function requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return { ok: false, error: `${flag} requires a value` };
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseConnectArgv(argv: string[]): Result<ConnectOptions, string> {
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_DASHBOARD_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ name, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchConnect(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseConnectArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
|
||||
if (options.gatewaySecret === "") {
|
||||
printCliLine("error: WORKFLOW_DASHBOARD_SECRET is required");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const clientToken = randomUUID();
|
||||
const app = createApp(storageRoot, clientToken);
|
||||
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
const stopWsClient = startGatewayWsClient({
|
||||
gatewayUrl: options.gatewayUrl,
|
||||
name: options.name,
|
||||
secret: options.gatewaySecret,
|
||||
appFetch: app.fetch,
|
||||
log,
|
||||
});
|
||||
|
||||
printCliLine("connected to gateway via WebSocket");
|
||||
|
||||
// Register with gateway for discovery
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
`ws://${options.name}`,
|
||||
options.gatewaySecret,
|
||||
clientToken,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
stopWsClient();
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
clientToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/api/gateway/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: localUrl, secret, clientToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
printCliLine(`gateway registration failed: ${resp.status} ${body}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
printCliLine(`gateway registration error: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterFromGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
secret: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fetch(`${gatewayUrl}/api/gateway/register/${name}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
} catch {
|
||||
// Best effort — process is exiting
|
||||
}
|
||||
}
|
||||
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
localUrl: string,
|
||||
secret: string,
|
||||
clientToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, localUrl, secret, clientToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dispatchConnect } from "./connect.js";
|
||||
export type { ConnectOptions } from "./types.js";
|
||||
@@ -0,0 +1,374 @@
|
||||
import { existsSync, statSync, watch } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
FORK_BRANCH_ROLE,
|
||||
readThreadsIndex,
|
||||
type ThreadIndex,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
type PumpState = {
|
||||
contentOffset: number;
|
||||
carry: string;
|
||||
};
|
||||
|
||||
function fileSize(path: string): number {
|
||||
try {
|
||||
return statSync(path).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
|
||||
const size = fileSize(path);
|
||||
if (size < state.contentOffset) {
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
if (size <= state.contentOffset) {
|
||||
return null;
|
||||
}
|
||||
const blob = Bun.file(path).slice(state.contentOffset, size);
|
||||
const chunk = await blob.text();
|
||||
state.contentOffset = size;
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
}
|
||||
|
||||
function parseNewLines(chunk: string, state: PumpState): string[] {
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed !== "") {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
type CasSseState = {
|
||||
printedHashes: Set<string>;
|
||||
lastHead: string | null;
|
||||
completionEmitted: boolean;
|
||||
};
|
||||
|
||||
type LiveSseStream = {
|
||||
writeSSE: (opts: { event: string; data: string; id: string }) => Promise<void>;
|
||||
};
|
||||
|
||||
function completionFromEndMeta(meta: Record<string, unknown>): {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
} | null {
|
||||
const returnCode = meta.returnCode;
|
||||
const summary = meta.summary;
|
||||
if (typeof returnCode !== "number" || typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { returnCode, summary };
|
||||
}
|
||||
|
||||
async function emitRecordsForHead(params: {
|
||||
storageRoot: string;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
headHash: string;
|
||||
sseState: CasSseState;
|
||||
stream: LiveSseStream;
|
||||
eventId: { n: number };
|
||||
}): Promise<boolean> {
|
||||
const cas = createCasStore(getGlobalCasDir(params.storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, params.headHash);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
for (const fr of chronological) {
|
||||
if (params.sseState.printedHashes.has(fr.hash)) {
|
||||
continue;
|
||||
}
|
||||
params.sseState.printedHashes.add(fr.hash);
|
||||
|
||||
const role = fr.payload.role;
|
||||
if (role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === END) {
|
||||
const wf = completionFromEndMeta(fr.payload.meta);
|
||||
if (wf !== null) {
|
||||
params.eventId.n++;
|
||||
await params.stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({
|
||||
type: "workflow-result",
|
||||
returnCode: wf.returnCode,
|
||||
content: wf.summary,
|
||||
timestamp: null,
|
||||
}),
|
||||
id: String(params.eventId.n),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
|
||||
const content =
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`;
|
||||
|
||||
params.eventId.n++;
|
||||
await params.stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({
|
||||
type: "role",
|
||||
role: fr.payload.role,
|
||||
contentHash: fr.payload.content,
|
||||
content,
|
||||
meta: fr.payload.meta,
|
||||
timestamp: fr.payload.timestamp,
|
||||
}),
|
||||
id: String(params.eventId.n),
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pumpThreadsJsonSse(params: {
|
||||
storageRoot: string;
|
||||
bundleDir: string;
|
||||
threadId: string;
|
||||
sseState: CasSseState;
|
||||
stream: LiveSseStream;
|
||||
eventId: { n: number };
|
||||
}): Promise<boolean> {
|
||||
let idx: ThreadIndex;
|
||||
try {
|
||||
idx = await readThreadsIndex(params.bundleDir);
|
||||
} catch {
|
||||
idx = {};
|
||||
}
|
||||
|
||||
const active = idx[params.threadId];
|
||||
|
||||
if (active === undefined) {
|
||||
if (params.sseState.completionEmitted) {
|
||||
return false;
|
||||
}
|
||||
const hist = await resolveThreadRecord(params.storageRoot, params.threadId);
|
||||
if (hist === null || hist.source !== "history") {
|
||||
return false;
|
||||
}
|
||||
params.sseState.completionEmitted = true;
|
||||
return await emitRecordsForHead({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
headHash: hist.head,
|
||||
sseState: params.sseState,
|
||||
stream: params.stream,
|
||||
eventId: params.eventId,
|
||||
});
|
||||
}
|
||||
|
||||
const head = active.head;
|
||||
if (params.sseState.lastHead === null) {
|
||||
params.sseState.lastHead = head;
|
||||
return await emitRecordsForHead({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
headHash: head,
|
||||
sseState: params.sseState,
|
||||
stream: params.stream,
|
||||
eventId: params.eventId,
|
||||
});
|
||||
}
|
||||
|
||||
if (head !== params.sseState.lastHead) {
|
||||
params.sseState.lastHead = head;
|
||||
return await emitRecordsForHead({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.bundleDir,
|
||||
threadId: params.threadId,
|
||||
headHash: head,
|
||||
sseState: params.sseState,
|
||||
stream: params.stream,
|
||||
eventId: params.eventId,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createLiveRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:threadId/live", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
|
||||
const threadTarget = resolved;
|
||||
const threadsJsonPath = join(threadTarget.bundleDir, "threads.json");
|
||||
const infoPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.info.jsonl`);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||
const sseThreadState: CasSseState = {
|
||||
printedHashes: new Set<string>(),
|
||||
lastHead: null,
|
||||
completionEmitted: false,
|
||||
};
|
||||
const eventId = { n: 0 };
|
||||
|
||||
async function pumpData(): Promise<boolean> {
|
||||
const finished = await pumpThreadsJsonSse({
|
||||
storageRoot,
|
||||
bundleDir: threadTarget.bundleDir,
|
||||
threadId,
|
||||
sseState: sseThreadState,
|
||||
stream,
|
||||
eventId,
|
||||
});
|
||||
return finished;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SSE newline framing mirrors legacy pump
|
||||
async function pumpInfo(): Promise<void> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(infoPath, infoState);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, infoState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
if (
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
"raw" in (record as Record<string, unknown>)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "info",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify({
|
||||
type: "thread-start",
|
||||
threadId: threadTarget.threadId,
|
||||
bundleHash: threadTarget.bundleHash,
|
||||
head: threadTarget.head,
|
||||
start: threadTarget.start,
|
||||
source: threadTarget.source,
|
||||
}),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
|
||||
const done = await pumpData();
|
||||
try {
|
||||
await pumpInfo();
|
||||
} catch {
|
||||
// optional info file
|
||||
}
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If thread is not actively running, emit all records and close — don't keep SSE open
|
||||
const runningPath = join(storageRoot, "logs", threadTarget.bundleHash, `${threadId}.running`);
|
||||
if (!existsSync(runningPath)) {
|
||||
eventId.n++;
|
||||
await stream.writeSSE({
|
||||
event: "done",
|
||||
data: JSON.stringify({ reason: "not-running" }),
|
||||
id: String(eventId.n),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
const threadsJsonWatcher = watch(threadsJsonPath, async () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
const finished = await pumpData();
|
||||
if (finished) {
|
||||
completed = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||
try {
|
||||
infoWatcher = watch(infoPath, async () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
await pumpInfo();
|
||||
});
|
||||
} catch {
|
||||
// info file may not exist
|
||||
}
|
||||
|
||||
stream.onAbort(() => {
|
||||
completed = true;
|
||||
threadsJsonWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (completed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
threadsJsonWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { HistoricalThreadRow, ResolvedThreadRecord } from "../../thread-scan.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadListStatus,
|
||||
resolveThreadRecord,
|
||||
} from "../../thread-scan.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||
import { cmdRun } from "../thread/run.js";
|
||||
|
||||
async function readStartInfo(
|
||||
cas: ReturnType<typeof createCasStore>,
|
||||
startHash: string,
|
||||
): Promise<{ name: string | null; prompt: string | null }> {
|
||||
const raw = await cas.get(startHash);
|
||||
if (raw === null) return { name: null, prompt: null };
|
||||
const parsed = parseCasThreadNode(raw);
|
||||
if (parsed === null || parsed.kind !== "start") return { name: null, prompt: null };
|
||||
const name = parsed.node.payload.name;
|
||||
const promptHash = parsed.node.refs[0] ?? null;
|
||||
let prompt: string | null = null;
|
||||
if (promptHash !== null) {
|
||||
prompt = await getContentMerklePayload(cas, promptHash);
|
||||
}
|
||||
return { name, prompt };
|
||||
}
|
||||
|
||||
async function buildThreadDetailRecords(
|
||||
storageRoot: string,
|
||||
resolved: ResolvedThreadRecord,
|
||||
runningMarkerPresent: boolean,
|
||||
statusRow: HistoricalThreadRow,
|
||||
): Promise<unknown[]> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const { name: workflowName, prompt } = await readStartInfo(cas, resolved.start);
|
||||
|
||||
const status = await resolveThreadListStatus(storageRoot, statusRow, runningMarkerPresent);
|
||||
|
||||
const records: unknown[] = [
|
||||
{
|
||||
type: "thread-start",
|
||||
workflow: workflowName ?? "unknown",
|
||||
prompt: prompt ?? null,
|
||||
threadId: resolved.threadId,
|
||||
status,
|
||||
timestamp: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
if (fr.payload.role === END) {
|
||||
const returnCode = fr.payload.meta.returnCode;
|
||||
const summary = fr.payload.meta.summary;
|
||||
if (typeof returnCode === "number" && typeof summary === "string") {
|
||||
records.push({
|
||||
type: "workflow-result",
|
||||
returnCode,
|
||||
content: summary,
|
||||
timestamp: fr.payload.timestamp,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
|
||||
const content =
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`;
|
||||
records.push({
|
||||
type: "role",
|
||||
role: fr.payload.role,
|
||||
contentHash: fr.payload.content,
|
||||
content,
|
||||
meta: fr.payload.meta,
|
||||
timestamp: fr.payload.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const nameFilter = c.req.query("workflow") ?? null;
|
||||
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||
const threads = await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
const runningPath = join(storageRoot, "logs", r.hash, `${r.threadId}.running`);
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const status = await resolveThreadListStatus(storageRoot, r, runningMarkerPresent);
|
||||
return {
|
||||
threadId: r.threadId,
|
||||
workflow: r.workflowName,
|
||||
hash: r.hash,
|
||||
startedAt: new Date(r.activityTs).toISOString(),
|
||||
status,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return c.json({ threads });
|
||||
});
|
||||
|
||||
app.get("/running", async (c) => {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/:threadId", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
|
||||
const runningMarkerPresent = await pathExists(runningPath);
|
||||
const statusRow = {
|
||||
threadId: resolved.threadId,
|
||||
hash: resolved.bundleHash,
|
||||
workflowName: null,
|
||||
source: resolved.source,
|
||||
activityTs: 0,
|
||||
head: resolved.head,
|
||||
};
|
||||
const records = await buildThreadDetailRecords(
|
||||
storageRoot,
|
||||
resolved,
|
||||
runningMarkerPresent,
|
||||
statusRow,
|
||||
);
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ threadId: result.value.threadId }, 201);
|
||||
});
|
||||
|
||||
app.post("/:threadId/kill", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/pause", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/resume", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
+16
-1
@@ -1,9 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { WorkflowDescriptor } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { Hono } from "hono";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
@@ -35,7 +40,17 @@ export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, ...entry });
|
||||
let descriptor: WorkflowDescriptor | null = null;
|
||||
try {
|
||||
const yamlPath = join(storageRoot, "bundles", `${entry.hash}.yaml`);
|
||||
const yamlText = await readFile(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(yamlText);
|
||||
const validated = validateWorkflowDescriptor(parsed);
|
||||
descriptor = validated.ok ? validated.value : null;
|
||||
} catch {
|
||||
descriptor = null;
|
||||
}
|
||||
return c.json({ name, ...entry, descriptor });
|
||||
});
|
||||
|
||||
app.get("/:name/history", async (c) => {
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ConnectOptions = {
|
||||
name: string;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { parseWsRequestJson, type WsResponse } from "@uncaged/workflow-gateway/ws-protocol";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
|
||||
export type GatewayWsClientParams = {
|
||||
gatewayUrl: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
appFetch: (request: Request) => Response | Promise<Response>;
|
||||
log: LogFn;
|
||||
};
|
||||
|
||||
const INITIAL_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
|
||||
export function buildGatewayWsConnectUrl(gatewayUrl: string, name: string, secret: string): string {
|
||||
const u = new URL(gatewayUrl);
|
||||
if (u.protocol === "https:") {
|
||||
u.protocol = "wss:";
|
||||
} else if (u.protocol === "http:") {
|
||||
u.protocol = "ws:";
|
||||
}
|
||||
u.pathname = "/ws/connect";
|
||||
u.search = "";
|
||||
u.searchParams.set("name", name);
|
||||
u.searchParams.set("secret", secret);
|
||||
return u.href;
|
||||
}
|
||||
|
||||
function headersToRecord(h: Headers): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of h) {
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function handleGatewayMessage(
|
||||
ws: WebSocket,
|
||||
raw: string,
|
||||
params: GatewayWsClientParams,
|
||||
): Promise<void> {
|
||||
const req = parseWsRequestJson(raw);
|
||||
if (req === null) {
|
||||
params.log("ZM8K2PQ1", "gateway WebSocket dropped non-request message");
|
||||
return;
|
||||
}
|
||||
const localUrl = `http://localhost${req.path}`;
|
||||
const headers = new Headers(req.headers);
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await params.appFetch(
|
||||
new Request(localUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.body === null ? undefined : req.body,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
params.log("R4N7BQ3C", `app.fetch failed: ${String(e)}`);
|
||||
const errBody: WsResponse = {
|
||||
id: req.id,
|
||||
status: 502,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ error: "local fetch failed", detail: String(e) }),
|
||||
};
|
||||
ws.send(JSON.stringify(errBody));
|
||||
return;
|
||||
}
|
||||
const bodyText = await resp.text();
|
||||
const headerRecord = headersToRecord(resp.headers);
|
||||
const out: WsResponse = {
|
||||
id: req.id,
|
||||
status: resp.status,
|
||||
headers: headerRecord,
|
||||
body: bodyText,
|
||||
};
|
||||
ws.send(JSON.stringify(out));
|
||||
}
|
||||
|
||||
/** Maintains a reverse WebSocket to the workflow gateway; reconnects with exponential backoff. */
|
||||
export function startGatewayWsClient(params: GatewayWsClientParams): () => void {
|
||||
const wsUrl = buildGatewayWsConnectUrl(params.gatewayUrl, params.name, params.secret);
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let attempt = 0;
|
||||
|
||||
const clearReconnectTimer = (): void => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
const delayMs = Math.min(INITIAL_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
params.log("6CJX2R8P", `gateway WebSocket reconnect in ${delayMs}ms (attempt ${attempt})`);
|
||||
reconnectTimer = setTimeout(connect, delayMs);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
params.log("2XK7HM9Q", "gateway WebSocket connecting...");
|
||||
try {
|
||||
socket = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
params.log("7NQW4HBT", `gateway WebSocket create failed: ${String(e)}`);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = socket;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
attempt = 0;
|
||||
params.log("4PWN3V82", "gateway WebSocket connected");
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (ev) => {
|
||||
socket = null;
|
||||
params.log(
|
||||
"8QTR6ZKC",
|
||||
`gateway WebSocket closed code=${String(ev.code)} reason=${ev.reason} wasClean=${String(ev.wasClean)}`,
|
||||
);
|
||||
if (!stopped) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
params.log("9BWS1M7F", "gateway WebSocket error");
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const data = ev.data;
|
||||
if (typeof data !== "string") {
|
||||
params.log("T9W2K35H", "gateway WebSocket non-text frame ignored");
|
||||
return;
|
||||
}
|
||||
void handleGatewayMessage(ws, data, params).catch((e: unknown) => {
|
||||
params.log("V7KX2M9P", `gateway WebSocket handler error: ${String(e)}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return (): void => {
|
||||
stopped = true;
|
||||
clearReconnectTimer();
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.close(1000, "shutdown");
|
||||
}
|
||||
socket = null;
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function templatePackageJson(templateName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -50,25 +50,19 @@ const greeterMetaSchema = z.object({
|
||||
export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
||||
description: "Says hello — replace with your first role.",
|
||||
systemPrompt: "You are a helpful assistant. Reply with one short friendly sentence.",
|
||||
extractPrompt: "Extract the assistant's greeting as message.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateModeratorTs(): string {
|
||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||
return `import { END, START, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
export const helloTemplateTable: ModeratorTable<HelloTemplateMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "greeter" }],
|
||||
greeter: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -76,7 +70,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import { helloTemplateTable } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
@@ -88,14 +82,14 @@ export {
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
export { helloTemplateTable } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
table: helloTemplateTable,
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function rootPackageJson(workspaceName: string): string {
|
||||
return `${JSON.stringify(
|
||||
@@ -14,6 +13,9 @@ function rootPackageJson(workspaceName: string): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
scripts: {
|
||||
bundle: "bun run scripts/bundle.ts",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -28,7 +30,7 @@ function workflowsPackageJson(): string {
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
"@uncaged/workflow-runtime": "^0.3.1",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
@@ -42,7 +44,9 @@ function biomeJson(): string {
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
// Exclude generated bundle script — it uses Bun globals and console that
|
||||
// conflict with the workspace's Biome rules (noConsole, etc.).
|
||||
includes: ["**", "!**/node_modules", "!**/dist", "!scripts/bundle.ts"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
@@ -85,29 +89,29 @@ function agentsMd(): string {
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **Workspace** | 仓库根(\`package.json\` 含 \`workspaces: ["templates/*", "workflows"]\`) | Bun monorepo:统一管理本地模板包与 workflow 实例 |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **Moderator**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AgentFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
| **Template** | \`templates/<name>/\`(如 \`src/roles.ts\`、\`src/moderator.ts\`、\`src/index.ts\`) | 纯数据:**WorkflowDefinition**(各 **RoleDefinition** + **ModeratorTable**),**不绑定**具体 Agent |
|
||||
| **Workflow instance** | \`workflows/\`(或单独包) | 把模板与运行时 **AdapterFn** / **ExtractFn** 组合,产出可注册的 **单文件 ESM bundle**(\`run\` + \`descriptor\` 命名导出) |
|
||||
|
||||
Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下放绑定与打包入口。
|
||||
|
||||
## 2. 核心概念
|
||||
|
||||
- **RoleMeta**:\`Record<string, Record<string, unknown>>\`,角色名 → 该角色结构化 meta 的形状约定。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`extractPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **Moderator**。
|
||||
- **Moderator**:\`(ctx: ModeratorContext<M>) => (角色名) | END\`。同步、纯函数,只做路由。
|
||||
- **AgentFn**:\`(ctx: AgentContext) => Promise<string>\`,原始文本输出;从上下文读取当前角色的 \`systemPrompt\`。
|
||||
- **ExtractFn**:从上下文与 prompt 解析结构化数据(引擎与 Agent 都可使用)。
|
||||
- **RoleDefinition<Meta>**:纯数据——\`description\`、\`systemPrompt\`、\`schema\`(Zod v4)。不含执行逻辑。
|
||||
- **WorkflowDefinition<M extends RoleMeta>**:\`description\` + \`roles\`(各角色定义)+ **ModeratorTable**(声明式路由表)。
|
||||
- **ModeratorTable**:从 \`START\` 与各角色名映射到有序 transition 列表(条件 + 下一角色或 \`END\`);可序列化,供描述符提取 **graph**。
|
||||
- **AdapterFn**:接收系统提示词与 Zod schema,返回角色执行函数(RoleFn)。
|
||||
- **ExtractFn**:从 CAS content hash 解析结构化数据(引擎与 Adapter 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **meta** → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
引擎循环简述:按 **ModeratorTable** 选下一角色 → **Adapter** 产出 typed meta → 追加 step,重复直至 **END**。详见 \`docs/architecture.md\` 中的三阶段说明。
|
||||
|
||||
## 3. 开发流程
|
||||
|
||||
1. **定义 RoleMeta**:为每个角色约定 meta 的 TypeScript 类型(与 Zod schema 对齐)。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`description\`。
|
||||
3. **编写 ModeratorTable**:为 \`START\` 与各角色声明 transition(\`FALLBACK\` 或命名条件 + \`check\`)。
|
||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / table 导出)。
|
||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AdapterFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowRuntime\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
@@ -153,7 +157,13 @@ uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ ModeratorTable → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function bunfigToml(): string {
|
||||
return `[install.scopes]
|
||||
"@uncaged" = "https://git.shazhou.work/api/packages/shazhou/npm/"
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,7 +174,7 @@ Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`templates/\` — reusable workflow definition packages (roles + ModeratorTable), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
@@ -184,32 +194,100 @@ uncaged-workflow init workspace ${workspaceName}
|
||||
`;
|
||||
}
|
||||
|
||||
function bundleTs(): string {
|
||||
return [
|
||||
'import { mkdir, readdir, writeFile } from "node:fs/promises";',
|
||||
'import { join } from "node:path";',
|
||||
"",
|
||||
'const rootDir = join(import.meta.dir, "..");',
|
||||
'const workflowsDir = join(rootDir, "workflows");',
|
||||
'const distDir = join(rootDir, "dist");',
|
||||
"",
|
||||
"function isEntryFile(name: string): boolean {",
|
||||
' return name.endsWith("-entry.ts");',
|
||||
"}",
|
||||
"",
|
||||
"function entryStem(name: string): string {",
|
||||
' return name.slice(0, -".ts".length);',
|
||||
"}",
|
||||
"",
|
||||
"async function main(): Promise<void> {",
|
||||
" await mkdir(distDir, { recursive: true });",
|
||||
" let files: string[];",
|
||||
" try {",
|
||||
" files = await readdir(workflowsDir);",
|
||||
" } catch {",
|
||||
' console.error("bundle: missing workflows/ directory");',
|
||||
" process.exitCode = 1;",
|
||||
" return;",
|
||||
" }",
|
||||
" const entries = files.filter(isEntryFile);",
|
||||
" if (entries.length === 0) {",
|
||||
' console.warn("bundle: no *-entry.ts files under workflows/");',
|
||||
" return;",
|
||||
" }",
|
||||
" for (const file of entries) {",
|
||||
" const stem = entryStem(file);",
|
||||
" const entryPath = join(workflowsDir, file);",
|
||||
" const result = await Bun.build({",
|
||||
" entrypoints: [entryPath],",
|
||||
" outdir: distDir,",
|
||||
' format: "esm",',
|
||||
' target: "node",',
|
||||
" splitting: false,",
|
||||
' naming: { entry: "[name].esm.js" },',
|
||||
" });",
|
||||
" if (!result.success) {",
|
||||
" for (const log of result.logs) {",
|
||||
" console.error(log);",
|
||||
" }",
|
||||
` throw new Error(\`bundle failed for \${file}\`);`,
|
||||
" }",
|
||||
" const dts =",
|
||||
` 'export { run, descriptor } from "../workflows/' + stem + '.js";\\n';`,
|
||||
` await writeFile(join(distDir, \`\${stem}.d.ts\`), dts, "utf8");`,
|
||||
` console.log(\`bundle: \${stem} -> dist/\${stem}.esm.js\`);`,
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
"await main();",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(workspaceName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
// Accept a relative/absolute path: resolve it and derive the dir name for package.json.
|
||||
const resolved = resolve(parentDir, workspaceName);
|
||||
const rootPath = resolved;
|
||||
const dirName = basename(resolved);
|
||||
|
||||
if (dirName === "" || dirName === "." || dirName === "..") {
|
||||
return err(`invalid workspace path: ${workspaceName}`);
|
||||
}
|
||||
|
||||
const rootPath = join(parentDir, workspaceName);
|
||||
if (await pathExists(rootPath)) {
|
||||
return err(`directory already exists: ${rootPath}`);
|
||||
}
|
||||
|
||||
await mkdir(rootPath, { recursive: false });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: false });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: false });
|
||||
await mkdir(rootPath, { recursive: true });
|
||||
await mkdir(join(rootPath, "templates"), { recursive: true });
|
||||
await mkdir(join(rootPath, "workflows"), { recursive: true });
|
||||
await mkdir(join(rootPath, "scripts"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "biome.json"), biomeJson(), "utf8"),
|
||||
writeFile(join(rootPath, "tsconfig.json"), tsconfigJson(), "utf8"),
|
||||
writeFile(join(rootPath, "AGENTS.md"), agentsMd(), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"),
|
||||
writeFile(join(rootPath, "README.md"), readmeMd(dirName), "utf8"),
|
||||
writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"),
|
||||
writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ rootPath });
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -1,198 +0,0 @@
|
||||
import { statSync, watch } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
|
||||
type PumpState = {
|
||||
contentOffset: number;
|
||||
carry: string;
|
||||
};
|
||||
|
||||
function fileSize(path: string): number {
|
||||
try {
|
||||
return statSync(path).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function readNewBytes(path: string, state: PumpState): Promise<string | null> {
|
||||
const size = fileSize(path);
|
||||
if (size < state.contentOffset) {
|
||||
// File was truncated — reset
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
if (size <= state.contentOffset) {
|
||||
return null;
|
||||
}
|
||||
const blob = Bun.file(path).slice(state.contentOffset, size);
|
||||
const chunk = await blob.text();
|
||||
state.contentOffset = size;
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function parseJsonLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
}
|
||||
|
||||
function isWorkflowResult(record: unknown): boolean {
|
||||
return (
|
||||
record !== null &&
|
||||
typeof record === "object" &&
|
||||
"type" in (record as Record<string, unknown>) &&
|
||||
(record as Record<string, unknown>).type === "workflow-result"
|
||||
);
|
||||
}
|
||||
|
||||
function parseNewLines(chunk: string, state: PumpState): string[] {
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed !== "") {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function createLiveRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/:threadId/live", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const resolvedDataPath = dataPath;
|
||||
|
||||
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const dataState: PumpState = { contentOffset: 0, carry: "" };
|
||||
const infoState: PumpState = { contentOffset: 0, carry: "" };
|
||||
let eventId = 0;
|
||||
|
||||
async function pumpData(): Promise<boolean> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(resolvedDataPath, dataState);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, dataState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
eventId++;
|
||||
await stream.writeSSE({
|
||||
event: "record",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId),
|
||||
});
|
||||
|
||||
if (isWorkflowResult(record)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function pumpInfo(): Promise<void> {
|
||||
let chunk: string | null;
|
||||
try {
|
||||
chunk = await readNewBytes(infoPath, infoState);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (chunk === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = parseNewLines(chunk, infoState);
|
||||
for (const line of lines) {
|
||||
const record = parseJsonLine(line);
|
||||
if (
|
||||
typeof record === "object" &&
|
||||
record !== null &&
|
||||
"raw" in (record as Record<string, unknown>)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
eventId++;
|
||||
await stream.writeSSE({
|
||||
event: "info",
|
||||
data: JSON.stringify(record),
|
||||
id: String(eventId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initial pump
|
||||
const done = await pumpData();
|
||||
await pumpInfo();
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for changes
|
||||
const controller = new AbortController();
|
||||
let completed = false;
|
||||
|
||||
const dataWatcher = watch(resolvedDataPath, async () => {
|
||||
if (completed) return;
|
||||
const finished = await pumpData();
|
||||
if (finished) {
|
||||
completed = true;
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let infoWatcher: ReturnType<typeof watch> | null = null;
|
||||
try {
|
||||
infoWatcher = watch(infoPath, async () => {
|
||||
if (completed) return;
|
||||
await pumpInfo();
|
||||
});
|
||||
} catch {
|
||||
// info file may not exist
|
||||
}
|
||||
|
||||
stream.onAbort(() => {
|
||||
completed = true;
|
||||
dataWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
|
||||
// Keep stream alive until completion or client disconnect
|
||||
await new Promise<void>((resolve) => {
|
||||
if (completed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
controller.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
stream.onAbort(() => resolve());
|
||||
});
|
||||
|
||||
dataWatcher.close();
|
||||
infoWatcher?.close();
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import {
|
||||
listHistoricalThreads,
|
||||
listRunningThreads,
|
||||
resolveThreadDataPath,
|
||||
} from "../../thread-scan.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
|
||||
import { cmdRun } from "../thread/run.js";
|
||||
|
||||
export function createThreadRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const nameFilter = c.req.query("workflow") ?? null;
|
||||
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/running", async (c) => {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
return c.json({ threads: rows });
|
||||
});
|
||||
|
||||
app.get("/:threadId", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return c.json({ error: `thread data missing: ${threadId}` }, 404);
|
||||
}
|
||||
const lines = text.trim().split("\n");
|
||||
const records = lines.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return { raw: line };
|
||||
}
|
||||
});
|
||||
return c.json({ threadId, records });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
|
||||
const name = body.workflow;
|
||||
const prompt = body.prompt;
|
||||
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
|
||||
|
||||
if (typeof name !== "string" || typeof prompt !== "string") {
|
||||
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
|
||||
}
|
||||
|
||||
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ threadId: result.value.threadId }, 201);
|
||||
});
|
||||
|
||||
app.post("/:threadId/kill", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/pause", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/:threadId/resume", async (c) => {
|
||||
const threadId = c.req.param("threadId");
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { serve } from "bun";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { createApp } from "./app.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
|
||||
export function startServer(storageRoot: string, options: ServeOptions): void {
|
||||
const app = createApp(storageRoot);
|
||||
|
||||
const server = serve({
|
||||
fetch: app.fetch,
|
||||
port: options.port,
|
||||
hostname: options.hostname,
|
||||
});
|
||||
|
||||
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||
}
|
||||
|
||||
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||
if (value === undefined) {
|
||||
return err("--port requires a value");
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||
return err(`invalid port: ${value}`);
|
||||
}
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port" || arg === "-p") {
|
||||
const portResult = parsePortValue(argv[i + 1]);
|
||||
if (!portResult.ok) {
|
||||
return portResult;
|
||||
}
|
||||
port = portResult.value;
|
||||
i++;
|
||||
} else if (arg === "--host") {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err("--host requires a value");
|
||||
}
|
||||
hostname = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
startServer(storageRoot, parsed.value);
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
|
||||
const setupDispatchLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
import { loadPresetProviders } from "./preset-providers.js";
|
||||
import { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
import type { SetupCliArgs } from "./types.js";
|
||||
|
||||
type OpenAiModelEntry = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type OpenAiModelsResponse = {
|
||||
data: OpenAiModelEntry[];
|
||||
};
|
||||
|
||||
function usageSetup(): string {
|
||||
return [
|
||||
"uncaged-workflow setup — configure workflow.yaml providers and default model",
|
||||
"",
|
||||
"Non-interactive (agent mode):",
|
||||
" uncaged-workflow setup \\",
|
||||
" --provider <name> \\",
|
||||
" --base-url <url> \\",
|
||||
" --api-key <key> \\",
|
||||
" --default-model <provider/model> \\",
|
||||
" [--init-workspace <name>]",
|
||||
"",
|
||||
"Interactive: run with no flags (prompts for each value).",
|
||||
"",
|
||||
"Storage: uses the same root as other commands (see UNCAGED_WORKFLOW_STORAGE_ROOT).",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function requireNext(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
type ParsedSetup = SetupCliArgs | "interactive" | "help";
|
||||
|
||||
type SetupFlagField = "provider" | "baseUrl" | "apiKey" | "defaultModel" | "initWorkspaceName";
|
||||
|
||||
const SETUP_FLAG_TO_FIELD: Record<string, SetupFlagField> = {
|
||||
"--provider": "provider",
|
||||
"--base-url": "baseUrl",
|
||||
"--api-key": "apiKey",
|
||||
"--default-model": "defaultModel",
|
||||
"--init-workspace": "initWorkspaceName",
|
||||
};
|
||||
|
||||
function emptyFlagState(): Record<SetupFlagField, string | null> {
|
||||
return {
|
||||
provider: null,
|
||||
baseUrl: null,
|
||||
apiKey: null,
|
||||
defaultModel: null,
|
||||
initWorkspaceName: null,
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeParsedSetup(
|
||||
state: Record<SetupFlagField, string | null>,
|
||||
): Result<ParsedSetup, string> {
|
||||
const hasAnyFlag =
|
||||
state.provider !== null ||
|
||||
state.baseUrl !== null ||
|
||||
state.apiKey !== null ||
|
||||
state.defaultModel !== null ||
|
||||
state.initWorkspaceName !== null;
|
||||
|
||||
if (!hasAnyFlag) {
|
||||
return ok("interactive");
|
||||
}
|
||||
|
||||
if (state.provider === null) {
|
||||
return err(
|
||||
"non-interactive setup requires --provider (or omit all flags for interactive mode)",
|
||||
);
|
||||
}
|
||||
|
||||
const missing: string[] = [];
|
||||
if (state.baseUrl === null) {
|
||||
missing.push("--base-url");
|
||||
}
|
||||
if (state.apiKey === null) {
|
||||
missing.push("--api-key");
|
||||
}
|
||||
if (state.defaultModel === null) {
|
||||
missing.push("--default-model");
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
return err(`missing required flag(s): ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const b = state.baseUrl;
|
||||
const k = state.apiKey;
|
||||
const m = state.defaultModel;
|
||||
if (b === null || k === null || m === null) {
|
||||
return err("internal: missing required flags after validation");
|
||||
}
|
||||
|
||||
return ok({
|
||||
provider: state.provider,
|
||||
baseUrl: b,
|
||||
apiKey: k,
|
||||
defaultModel: m,
|
||||
initWorkspaceName: state.initWorkspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetupArgv(argv: string[]): Result<ParsedSetup, string> {
|
||||
const state = emptyFlagState();
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const tok = argv[i];
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
if (tok === "--help" || tok === "-h") {
|
||||
return ok("help");
|
||||
}
|
||||
const field = SETUP_FLAG_TO_FIELD[tok];
|
||||
if (field === undefined) {
|
||||
return err(`unknown argument: ${tok}`);
|
||||
}
|
||||
const v = requireNext(argv, i, tok);
|
||||
if (!v.ok) {
|
||||
return v;
|
||||
}
|
||||
state[field] = v.value;
|
||||
i++;
|
||||
}
|
||||
|
||||
return finalizeParsedSetup(state);
|
||||
}
|
||||
|
||||
async function promptLine(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
const raw = await rl.question(label);
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
type SecretInputState = {
|
||||
buf: string;
|
||||
rawWasSet: boolean;
|
||||
onData: (chunk: string) => void;
|
||||
fulfill: (value: string) => void;
|
||||
};
|
||||
|
||||
function isLineTerminator(c: string): boolean {
|
||||
return c === "\n" || c === "\r" || c === "\u0004";
|
||||
}
|
||||
|
||||
function handleLineTerminator(state: SecretInputState): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(state.rawWasSet);
|
||||
}
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", state.onData);
|
||||
process.stdout.write("\n");
|
||||
state.fulfill(state.buf.trim());
|
||||
}
|
||||
|
||||
function handleBackspace(state: SecretInputState): void {
|
||||
if (state.buf.length > 0) {
|
||||
state.buf = state.buf.slice(0, -1);
|
||||
process.stdout.write("\b \b");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInterrupt(rawWasSet: boolean): void {
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(rawWasSet);
|
||||
}
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
function isBackspace(c: string): boolean {
|
||||
return c === "\u007F" || c === "\b";
|
||||
}
|
||||
|
||||
/** Process a single character in secret input. Returns "done" to stop reading. */
|
||||
function processSecretChar(c: string, state: SecretInputState): "done" | "skip" | "append" {
|
||||
if (isLineTerminator(c)) {
|
||||
handleLineTerminator(state);
|
||||
return "done";
|
||||
}
|
||||
if (isBackspace(c)) {
|
||||
handleBackspace(state);
|
||||
return "skip";
|
||||
}
|
||||
if (c === "\u0003") {
|
||||
handleInterrupt(state.rawWasSet);
|
||||
}
|
||||
state.buf += c;
|
||||
process.stdout.write("*");
|
||||
return "append";
|
||||
}
|
||||
|
||||
/** Read a line with terminal echo disabled (for secrets). */
|
||||
async function promptSecret(label: string): Promise<string> {
|
||||
process.stdout.write(label);
|
||||
return new Promise((fulfill) => {
|
||||
const rawWasSet = process.stdin.isRaw;
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
const state: SecretInputState = { buf: "", rawWasSet, fulfill, onData: () => {} };
|
||||
|
||||
const onData = (chunk: string) => {
|
||||
for (const c of chunk.toString()) {
|
||||
if (processSecretChar(c, state) === "done") return;
|
||||
}
|
||||
};
|
||||
|
||||
state.onData = onData;
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch available models from an OpenAI-compatible /models endpoint. */
|
||||
async function fetchAvailableModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
const url = `${baseUrl.replace(/\/+$/, "")}/models`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setupDispatchLog("R5KH7WM3", `GET ${url} returned ${res.status}`);
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as OpenAiModelsResponse;
|
||||
if (!Array.isArray(body.data)) {
|
||||
return [];
|
||||
}
|
||||
// Filter out non-chat models. Some patterns are DashScope-specific (sambert, cosyvoice,
|
||||
// wordart, wanx, wan2, paraformer) but harmless for other providers.
|
||||
const NON_CHAT_RE =
|
||||
/speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|z-image|s2s|livetranslate|realtime|gui-/i;
|
||||
return body.data
|
||||
.map((m) => m.id)
|
||||
.filter((id) => !NON_CHAT_RE.test(id))
|
||||
.sort();
|
||||
} catch (e) {
|
||||
setupDispatchLog(
|
||||
"V8NQ4JT6",
|
||||
`fetch models failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type PresetProvider = ReturnType<typeof loadPresetProviders>[number];
|
||||
|
||||
function printProviderMenu(presets: readonly PresetProvider[]): void {
|
||||
const numWidth = String(presets.length + 1).length;
|
||||
printCliLine("Select a provider:\n");
|
||||
for (let i = 0; i < presets.length; i++) {
|
||||
const p = presets.at(i);
|
||||
if (!p) continue;
|
||||
const num = String(i + 1).padStart(numWidth);
|
||||
printCliLine(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`);
|
||||
}
|
||||
const customNum = String(presets.length + 1).padStart(numWidth);
|
||||
printCliLine(` ${customNum}) Custom (enter name and URL manually)`);
|
||||
printCliLine("");
|
||||
}
|
||||
|
||||
async function selectProvider(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
presets: readonly PresetProvider[],
|
||||
): Promise<Result<{ provider: string; baseUrl: string }, string>> {
|
||||
const choice = await promptLine(rl, `Choose [1-${presets.length + 1}]: `);
|
||||
const choiceNum = Number.parseInt(choice, 10);
|
||||
if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > presets.length + 1) {
|
||||
return err(`invalid choice: ${choice}`);
|
||||
}
|
||||
|
||||
if (choiceNum <= presets.length) {
|
||||
const selected = presets.at(choiceNum - 1);
|
||||
if (!selected) return err(`invalid choice: ${choice}`);
|
||||
printCliLine(`\n → ${selected.label} (${selected.baseUrl})\n`);
|
||||
return ok({ provider: selected.name, baseUrl: selected.baseUrl });
|
||||
}
|
||||
|
||||
const provider = await promptLine(rl, "Provider name (e.g. my-proxy): ");
|
||||
if (provider === "") return err("provider name must not be empty");
|
||||
const baseUrl = await promptLine(rl, "OpenAI-compatible API base URL: ");
|
||||
if (baseUrl === "") return err("base URL must not be empty");
|
||||
return ok({ provider, baseUrl });
|
||||
}
|
||||
|
||||
function printModelList(models: string[]): void {
|
||||
const cols = process.stdout.columns || 80;
|
||||
const nw = String(models.length).length;
|
||||
const prefixLen = nw + 4;
|
||||
const maxModelLen = Math.max(...models.map((m) => m.length));
|
||||
const cellWidth = prefixLen + maxModelLen + 2;
|
||||
const numCols = Math.max(1, Math.floor(cols / cellWidth));
|
||||
for (let i = 0; i < models.length; i += numCols) {
|
||||
const cells: string[] = [];
|
||||
for (let j = i; j < Math.min(i + numCols, models.length); j++) {
|
||||
const num = String(j + 1).padStart(nw);
|
||||
const model = models.at(j) ?? "";
|
||||
cells.push(` ${num}) ${model.padEnd(maxModelLen + 2)}`);
|
||||
}
|
||||
printCliLine(cells.join(""));
|
||||
}
|
||||
}
|
||||
|
||||
async function selectModel(
|
||||
rl: { question: (q: string) => Promise<string> },
|
||||
models: string[],
|
||||
): Promise<Result<string, string>> {
|
||||
if (models.length > 0) {
|
||||
printCliLine(`\nAvailable models (${models.length}):\n`);
|
||||
printModelList(models);
|
||||
printCliLine(`\nChoose a number, or type a model name directly.`);
|
||||
const modelInput = await promptLine(rl, `Default model [1-${models.length}]: `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
const modelNum = Number.parseInt(modelInput, 10);
|
||||
if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) {
|
||||
return ok(models.at(modelNum - 1) ?? modelInput);
|
||||
}
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
printCliWarn("Could not fetch models (API may not support /models endpoint).");
|
||||
const modelInput = await promptLine(rl, `Default model (e.g. qwen-plus, gpt-4o): `);
|
||||
if (modelInput === "") return err("default model must not be empty");
|
||||
return ok(modelInput);
|
||||
}
|
||||
|
||||
async function selectWorkspace(rl: {
|
||||
question: (q: string) => Promise<string>;
|
||||
}): Promise<string | null> {
|
||||
while (true) {
|
||||
const wsPath = await promptLine(
|
||||
rl,
|
||||
"\nWorkflow workspace path (default: ./workflows, type 'skip' to skip): ",
|
||||
);
|
||||
if (wsPath.toLowerCase() === "skip") return null;
|
||||
const candidate = wsPath === "" ? "./workflows" : wsPath;
|
||||
const resolved = resolvePath(process.cwd(), candidate);
|
||||
if (existsSync(resolved)) {
|
||||
printCliWarn(`directory already exists: ${resolved}`);
|
||||
printCliLine("Please enter a different path, or type 'skip' to skip.");
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
function stripProviderPrefix(model: string): string {
|
||||
if (model.includes("/")) {
|
||||
return model.split("/").pop() ?? model;
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
async function collectInteractiveSetup(): Promise<Result<SetupCliArgs, string>> {
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
printCliLine("Configure the LLM provider that workflow agents will use.\n");
|
||||
|
||||
const presets = loadPresetProviders();
|
||||
printProviderMenu(presets);
|
||||
|
||||
const providerResult = await selectProvider(rl, presets);
|
||||
if (!providerResult.ok) {
|
||||
rl.close();
|
||||
return providerResult;
|
||||
}
|
||||
const { provider, baseUrl } = providerResult.value;
|
||||
|
||||
rl.close();
|
||||
const apiKey = await promptSecret("API key for this provider: ");
|
||||
if (apiKey === "") return err("API key must not be empty");
|
||||
const rl2 = createInterface({ input, output });
|
||||
|
||||
printCliLine("\nFetching available models...");
|
||||
const models = await fetchAvailableModels(baseUrl, apiKey);
|
||||
const modelResult = await selectModel(rl2, models);
|
||||
if (!modelResult.ok) {
|
||||
rl2.close();
|
||||
return modelResult;
|
||||
}
|
||||
|
||||
const bare = stripProviderPrefix(modelResult.value);
|
||||
const defaultModel = `${provider}/${bare}`;
|
||||
printCliLine(` → ${defaultModel}`);
|
||||
|
||||
const initWorkspaceName = await selectWorkspace(rl2);
|
||||
rl2.close();
|
||||
|
||||
return ok({ provider, baseUrl, apiKey, defaultModel, initWorkspaceName });
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchSetup(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseSetupArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${parsed.error}\n\n${usageSetup()}`);
|
||||
return 1;
|
||||
}
|
||||
if (parsed.value === "help") {
|
||||
printCliLine(usageSetup());
|
||||
return 0;
|
||||
}
|
||||
|
||||
let args: SetupCliArgs;
|
||||
if (parsed.value === "interactive") {
|
||||
const collected = await collectInteractiveSetup();
|
||||
if (!collected.ok) {
|
||||
printCliError(collected.error);
|
||||
return 1;
|
||||
}
|
||||
args = collected.value;
|
||||
} else {
|
||||
args = parsed.value;
|
||||
}
|
||||
|
||||
const result = await cmdSetup(storageRoot, args);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printSetupSummary(result.value);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { dispatchSetup } from "./dispatch.js";
|
||||
export { loadPresetProviders } from "./preset-providers.js";
|
||||
export { cmdSetup, printSetupSummary } from "./setup.js";
|
||||
export type { CmdSetupSuccess, PresetProvider, SetupCliArgs } from "./types.js";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { PresetProvider } from "./types.js";
|
||||
|
||||
type RawPresetEntry = {
|
||||
name: unknown;
|
||||
label: unknown;
|
||||
baseUrl: unknown;
|
||||
};
|
||||
|
||||
function isRawEntry(v: unknown): v is RawPresetEntry {
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
return typeof o.name === "string" && typeof o.label === "string" && typeof o.baseUrl === "string";
|
||||
}
|
||||
|
||||
let cached: ReadonlyArray<PresetProvider> | null = null;
|
||||
|
||||
export function loadPresetProviders(): ReadonlyArray<PresetProvider> {
|
||||
if (cached !== null) return cached;
|
||||
|
||||
const yamlPath = join(import.meta.dirname, "providers.yaml");
|
||||
const raw = readFileSync(yamlPath, "utf8");
|
||||
const parsed: unknown = parseYaml(raw);
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`providers.yaml: expected array, got ${typeof parsed}`);
|
||||
}
|
||||
|
||||
const result: PresetProvider[] = [];
|
||||
for (const entry of parsed) {
|
||||
if (!isRawEntry(entry)) {
|
||||
throw new Error(`providers.yaml: invalid entry: ${JSON.stringify(entry)}`);
|
||||
}
|
||||
result.push({
|
||||
name: entry.name as string,
|
||||
label: entry.label as string,
|
||||
baseUrl: entry.baseUrl as string,
|
||||
});
|
||||
}
|
||||
|
||||
cached = result;
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Preset LLM providers for `uncaged-workflow setup`.
|
||||
# Each entry needs a provider name (used in workflow.yaml) and an OpenAI-compatible base URL.
|
||||
# Add new providers here — no code changes required.
|
||||
|
||||
# ── 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: glm-intl
|
||||
label: GLM (Zhipu AI Intl)
|
||||
baseUrl: https://api.z.ai/api/paas/v4
|
||||
|
||||
- name: stepfun
|
||||
label: StepFun
|
||||
baseUrl: https://api.stepfun.com/v1
|
||||
|
||||
- name: minimax
|
||||
label: MiniMax
|
||||
baseUrl: https://api.minimax.io/v1
|
||||
|
||||
- name: tencent
|
||||
label: Tencent TokenHub
|
||||
baseUrl: https://tokenhub.tencentmaas.com/v1
|
||||
|
||||
- name: xiaomi
|
||||
label: Xiaomi MiMo
|
||||
baseUrl: https://api.xiaomimimo.com/v1
|
||||
|
||||
# ── Local ──────────────────────────────────────────────────
|
||||
|
||||
- name: ollama
|
||||
label: Ollama (local)
|
||||
baseUrl: http://localhost:11434/v1
|
||||
@@ -0,0 +1,103 @@
|
||||
import { err, ok, type Result, type WorkflowConfig } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
splitProviderModelRef,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
import { cmdInitWorkspace } from "../init/index.js";
|
||||
import type { CmdSetupSuccess, SetupCliArgs } from "./types.js";
|
||||
|
||||
const setupLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function mergeWorkflowConfig(
|
||||
prev: WorkflowConfig | null,
|
||||
input: SetupCliArgs,
|
||||
): Result<WorkflowConfig, string> {
|
||||
const modelSplit = splitProviderModelRef(input.defaultModel);
|
||||
if (!modelSplit.ok) {
|
||||
return err(modelSplit.error);
|
||||
}
|
||||
if (modelSplit.value.providerName !== input.provider) {
|
||||
return err(
|
||||
`default model provider "${modelSplit.value.providerName}" must match --provider "${input.provider}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const maxDepth = prev === null ? 3 : prev.maxDepth;
|
||||
const supervisorInterval = prev === null ? 3 : prev.supervisorInterval;
|
||||
const providers = {
|
||||
...(prev === null ? {} : prev.providers),
|
||||
[input.provider]: { baseUrl: input.baseUrl, apiKey: input.apiKey },
|
||||
};
|
||||
const models = { ...(prev === null ? {} : prev.models), default: input.defaultModel };
|
||||
|
||||
return ok({
|
||||
maxDepth,
|
||||
supervisorInterval,
|
||||
providers,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmdSetup(
|
||||
storageRoot: string,
|
||||
input: SetupCliArgs,
|
||||
): Promise<Result<CmdSetupSuccess, string>> {
|
||||
const readResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!readResult.ok) {
|
||||
setupLog("W8JH4Q2K", `read workflow registry failed: ${readResult.error.message}`);
|
||||
return err(readResult.error.message);
|
||||
}
|
||||
|
||||
const current = readResult.value;
|
||||
const merged = mergeWorkflowConfig(current.config, input);
|
||||
if (!merged.ok) {
|
||||
return merged;
|
||||
}
|
||||
const nextConfig = merged.value;
|
||||
const nextRegistry = {
|
||||
config: nextConfig,
|
||||
workflows: current.workflows,
|
||||
};
|
||||
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
if (!written.ok) {
|
||||
setupLog("M2NB5VX9", `write workflow registry failed: ${written.error.message}`);
|
||||
return err(written.error.message);
|
||||
}
|
||||
|
||||
const registryPath = workflowRegistryPath(storageRoot);
|
||||
|
||||
let initWorkspaceRootPath: string | null = null;
|
||||
if (input.initWorkspaceName !== null) {
|
||||
const initResult = await cmdInitWorkspace(process.cwd(), input.initWorkspaceName);
|
||||
if (!initResult.ok) {
|
||||
setupLog("T7QC4HWP", `init workspace failed: ${initResult.error}`);
|
||||
return err(initResult.error);
|
||||
}
|
||||
initWorkspaceRootPath = initResult.value.rootPath;
|
||||
}
|
||||
|
||||
return ok({
|
||||
registryPath,
|
||||
provider: input.provider,
|
||||
defaultModel: input.defaultModel,
|
||||
maxDepth: nextConfig.maxDepth,
|
||||
supervisorInterval: nextConfig.supervisorInterval,
|
||||
initWorkspaceRootPath,
|
||||
});
|
||||
}
|
||||
|
||||
export function printSetupSummary(result: CmdSetupSuccess): void {
|
||||
printCliLine(`wrote registry: ${result.registryPath}`);
|
||||
printCliLine(`provider "${result.provider}" (baseUrl + apiKey updated)`);
|
||||
printCliLine(`config.models.default = "${result.defaultModel}"`);
|
||||
printCliLine(`maxDepth=${result.maxDepth}, supervisorInterval=${result.supervisorInterval}`);
|
||||
if (result.initWorkspaceRootPath !== null) {
|
||||
printCliLine(`initialized workflow workspace at ${result.initWorkspaceRootPath}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Parsed non-interactive `setup` CLI arguments (all fields required for agent mode). */
|
||||
export type SetupCliArgs = {
|
||||
provider: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
initWorkspaceName: string | null;
|
||||
};
|
||||
|
||||
export type PresetProvider = {
|
||||
name: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export type CmdSetupSuccess = {
|
||||
registryPath: string;
|
||||
provider: string;
|
||||
defaultModel: string;
|
||||
maxDepth: number;
|
||||
supervisorInterval: number;
|
||||
initWorkspaceRootPath: string | null;
|
||||
};
|
||||
@@ -26,12 +26,7 @@ export async function dispatchRun(storageRoot: string, argv: string[]): Promise<
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
const result = await cmdRun(storageRoot, parsed.value.name, parsed.value.prompt);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
@@ -166,7 +161,7 @@ export async function dispatchFork(storageRoot: string, argv: string[]): Promise
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
args: "<name> [--prompt <text>]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { join } from "node:path";
|
||||
import { buildForkPlan } from "@uncaged/workflow-execute";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { prepareCasFork } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { generateUlid, getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
|
||||
export async function cmdFork(
|
||||
@@ -12,49 +13,51 @@ export async function cmdFork(
|
||||
threadId: string,
|
||||
fromRole: string | null,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${resolved.bundleHash}.esm.js`);
|
||||
if (!(await pathExists(bundlePath))) {
|
||||
return err(`bundle file missing for thread hash ${resolved.bundleHash}`);
|
||||
}
|
||||
|
||||
const plan = buildForkPlan(text, fromRole);
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
|
||||
const plan = await prepareCasFork({
|
||||
cas,
|
||||
bundleDir: resolved.bundleDir,
|
||||
bundleHash: resolved.bundleHash,
|
||||
sourceThreadId: threadId,
|
||||
headHash: resolved.head,
|
||||
startHash: resolved.start,
|
||||
newThreadId,
|
||||
fromRole,
|
||||
});
|
||||
if (!plan.ok) {
|
||||
return plan;
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${plan.value.hash}.esm.js`);
|
||||
if (!(await pathExists(bundlePath))) {
|
||||
return err(`bundle file missing for thread hash ${plan.value.hash}`);
|
||||
}
|
||||
|
||||
const worker = await ensureWorkerForHash(storageRoot, plan.value.hash, bundlePath);
|
||||
if (!worker.ok) {
|
||||
return worker;
|
||||
}
|
||||
|
||||
const newThreadId = generateUlid(Date.now());
|
||||
const stepsOnWire = plan.value.historicalSteps.map((s) => ({
|
||||
role: s.role,
|
||||
contentHash: s.contentHash,
|
||||
meta: s.meta,
|
||||
refs: s.refs,
|
||||
timestamp: s.timestamp,
|
||||
}));
|
||||
|
||||
const p = plan.value;
|
||||
const sent = await sendWorkerTcpCommand(
|
||||
worker.value.port,
|
||||
{
|
||||
type: "run",
|
||||
threadId: newThreadId,
|
||||
workflowName: plan.value.workflowName,
|
||||
prompt: plan.value.prompt,
|
||||
options: plan.value.runOptions,
|
||||
steps: stepsOnWire,
|
||||
forkSourceThreadId: plan.value.sourceThreadId,
|
||||
workflowName: p.workflowName,
|
||||
prompt: p.prompt,
|
||||
options: p.runOptions,
|
||||
steps: p.steps,
|
||||
stepTimestamps: p.stepTimestamps.length > 0 ? p.stepTimestamps : null,
|
||||
forkSourceThreadId: threadId,
|
||||
forkContinuation: p.forkContinuation,
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { watch } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
import { tryParseRoleStepRecord, tryParseWorkflowResultRecord } from "@uncaged/workflow-execute";
|
||||
import {
|
||||
FORK_BRANCH_ROLE,
|
||||
readThreadsIndex,
|
||||
type ThreadIndex,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import type { CasStore, WorkflowCompletion } from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import type { ParsedLiveArgv } from "../../live-argv.js";
|
||||
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import {
|
||||
findLatestThreadBundleTarget,
|
||||
type LatestThreadTarget,
|
||||
resolveThreadRecord,
|
||||
} from "../../thread-scan.js";
|
||||
import type { LiveRoleRow } from "./types.js";
|
||||
|
||||
export const LIVE_CONTENT_MAX_LINES = 10;
|
||||
@@ -48,16 +58,15 @@ function printSummary(result: WorkflowCompletion): void {
|
||||
printCliLine(`completed: returnCode=${result.returnCode} — ${result.summary}`);
|
||||
}
|
||||
|
||||
type LiveSessionState = {
|
||||
sawStart: boolean;
|
||||
completed: boolean;
|
||||
type InfoLiveState = {
|
||||
carry: string;
|
||||
contentOffset: number;
|
||||
};
|
||||
|
||||
type InfoLiveState = {
|
||||
carry: string;
|
||||
contentOffset: number;
|
||||
type CasLiveState = {
|
||||
printedHashes: Set<string>;
|
||||
lastHead: string | null;
|
||||
completionEmitted: boolean;
|
||||
};
|
||||
|
||||
function tryParseInfoRecord(obj: Record<string, unknown>): {
|
||||
@@ -79,102 +88,140 @@ function tryParseInfoRecord(obj: Record<string, unknown>): {
|
||||
return { tag, content, timestamp };
|
||||
}
|
||||
|
||||
async function handleJsonlLine(
|
||||
rawLine: string,
|
||||
state: LiveSessionState,
|
||||
roleFilter: string | null,
|
||||
cas: CasStore,
|
||||
): Promise<{ parseError: string | null; workflowResult: WorkflowCompletion | null }> {
|
||||
const trimmed = rawLine.trim();
|
||||
if (trimmed === "") {
|
||||
return { parseError: null, workflowResult: null };
|
||||
function completionFromEndMeta(meta: Record<string, unknown>): WorkflowCompletion | null {
|
||||
const returnCode = meta.returnCode;
|
||||
const summary = meta.summary;
|
||||
if (typeof returnCode !== "number" || typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { returnCode, summary };
|
||||
}
|
||||
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
return { parseError: "invalid JSON in thread data file", workflowResult: null };
|
||||
async function emitRoleStepPrint(params: {
|
||||
cas: CasStore;
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
roleFilter: string | null;
|
||||
}): Promise<void> {
|
||||
if (params.roleFilter !== null && params.role !== params.roleFilter) {
|
||||
return;
|
||||
}
|
||||
if (rec === null || typeof rec !== "object") {
|
||||
return { parseError: "invalid record in thread data file", workflowResult: null };
|
||||
}
|
||||
const obj = rec as Record<string, unknown>;
|
||||
|
||||
if (!state.sawStart) {
|
||||
state.sawStart = true;
|
||||
return { parseError: null, workflowResult: null };
|
||||
}
|
||||
|
||||
const wf = tryParseWorkflowResultRecord(obj);
|
||||
if (wf !== null) {
|
||||
state.completed = true;
|
||||
return { parseError: null, workflowResult: wf };
|
||||
}
|
||||
|
||||
const roleRow = tryParseRoleStepRecord(obj);
|
||||
if (roleRow === null) {
|
||||
return {
|
||||
parseError: "unrecognized record in thread data (expected role step or result)",
|
||||
workflowResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (roleFilter !== null && roleRow.role !== roleFilter) {
|
||||
return { parseError: null, workflowResult: null };
|
||||
}
|
||||
|
||||
const payload = await getContentMerklePayload(cas, roleRow.contentHash);
|
||||
const payload = await getContentMerklePayload(params.cas, params.contentHash);
|
||||
const content =
|
||||
payload !== null ? payload : `(content not in CAS; contentHash=${roleRow.contentHash})`;
|
||||
payload !== null ? payload : `(content not in CAS; contentHash=${params.contentHash})`;
|
||||
|
||||
const row: LiveRoleRow = {
|
||||
role: roleRow.role,
|
||||
role: params.role,
|
||||
content,
|
||||
meta: roleRow.meta,
|
||||
timestamp: roleRow.timestamp,
|
||||
meta: params.meta,
|
||||
timestamp: params.timestamp,
|
||||
};
|
||||
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
|
||||
printCliLine(outLine);
|
||||
}
|
||||
return { parseError: null, workflowResult: null };
|
||||
}
|
||||
|
||||
async function pumpNewContent(
|
||||
dataPath: string,
|
||||
state: LiveSessionState,
|
||||
roleFilter: string | null,
|
||||
cas: CasStore,
|
||||
): Promise<number | null> {
|
||||
let text: string;
|
||||
async function emitStatesReachableFromHead(params: {
|
||||
cas: CasStore;
|
||||
headHash: string;
|
||||
state: CasLiveState;
|
||||
roleFilter: string | null;
|
||||
}): Promise<WorkflowCompletion | null> {
|
||||
const frames = await walkStateFramesNewestFirst(params.cas, params.headHash);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
for (const fr of chronological) {
|
||||
if (params.state.printedHashes.has(fr.hash)) {
|
||||
continue;
|
||||
}
|
||||
params.state.printedHashes.add(fr.hash);
|
||||
|
||||
const role = fr.payload.role;
|
||||
if (role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === END) {
|
||||
const wf = completionFromEndMeta(fr.payload.meta);
|
||||
if (wf !== null) {
|
||||
printSummary(wf);
|
||||
return wf;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await emitRoleStepPrint({
|
||||
cas: params.cas,
|
||||
role,
|
||||
contentHash: fr.payload.content,
|
||||
meta: fr.payload.meta,
|
||||
timestamp: fr.payload.timestamp,
|
||||
roleFilter: params.roleFilter,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function pumpThreadsJson(params: {
|
||||
storageRoot: string;
|
||||
bundleDir: string;
|
||||
bundleHash: string;
|
||||
threadId: string;
|
||||
state: CasLiveState;
|
||||
roleFilter: string | null;
|
||||
cas: CasStore;
|
||||
}): Promise<number | null> {
|
||||
let idx: ThreadIndex;
|
||||
try {
|
||||
text = await readFile(dataPath, "utf8");
|
||||
idx = await readThreadsIndex(params.bundleDir);
|
||||
} catch {
|
||||
return null;
|
||||
idx = {};
|
||||
}
|
||||
|
||||
if (text.length < state.contentOffset) {
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
const active = idx[params.threadId];
|
||||
|
||||
if (active === undefined) {
|
||||
if (params.state.completionEmitted) {
|
||||
return null;
|
||||
}
|
||||
const hist = await resolveThreadRecord(params.storageRoot, params.threadId);
|
||||
if (hist === null || hist.source !== "history") {
|
||||
return null;
|
||||
}
|
||||
params.state.completionEmitted = true;
|
||||
const wf = await emitStatesReachableFromHead({
|
||||
cas: params.cas,
|
||||
headHash: hist.head,
|
||||
state: params.state,
|
||||
roleFilter: params.roleFilter,
|
||||
});
|
||||
return wf !== null ? 0 : null;
|
||||
}
|
||||
|
||||
const chunk = text.slice(state.contentOffset);
|
||||
state.contentOffset = text.length;
|
||||
state.carry += chunk;
|
||||
const head = active.head;
|
||||
if (params.state.lastHead === null) {
|
||||
params.state.lastHead = head;
|
||||
const wf = await emitStatesReachableFromHead({
|
||||
cas: params.cas,
|
||||
headHash: head,
|
||||
state: params.state,
|
||||
roleFilter: params.roleFilter,
|
||||
});
|
||||
return wf !== null ? 0 : null;
|
||||
}
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
for (const line of parts) {
|
||||
const { parseError, workflowResult } = await handleJsonlLine(line, state, roleFilter, cas);
|
||||
if (parseError !== null) {
|
||||
printCliError(parseError);
|
||||
return 1;
|
||||
}
|
||||
if (workflowResult !== null) {
|
||||
printSummary(workflowResult);
|
||||
return 0;
|
||||
}
|
||||
if (head !== params.state.lastHead) {
|
||||
params.state.lastHead = head;
|
||||
const wf = await emitStatesReachableFromHead({
|
||||
cas: params.cas,
|
||||
headHash: head,
|
||||
state: params.state,
|
||||
roleFilter: params.roleFilter,
|
||||
});
|
||||
return wf !== null ? 0 : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -291,9 +338,9 @@ function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal })
|
||||
schedulePump(path, pump);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
watcher.on("error", (err: Error) => {
|
||||
watcher.on("error", (errObj: Error) => {
|
||||
closeAll();
|
||||
reject(err);
|
||||
reject(errObj);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,17 +356,14 @@ function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal })
|
||||
});
|
||||
}
|
||||
|
||||
type LiveThreadTarget = {
|
||||
threadId: string;
|
||||
dataPath: string;
|
||||
};
|
||||
type LiveThreadTarget = LatestThreadTarget;
|
||||
|
||||
async function resolveLiveThreadTarget(
|
||||
storageRoot: string,
|
||||
parsed: ParsedLiveArgv,
|
||||
): Promise<LiveThreadTarget | null> {
|
||||
if (parsed.latest) {
|
||||
const found = await findLatestThreadDataPath(storageRoot);
|
||||
const found = await findLatestThreadBundleTarget(storageRoot);
|
||||
if (found === null) {
|
||||
printCliError("live: no threads found");
|
||||
return null;
|
||||
@@ -332,36 +376,56 @@ async function resolveLiveThreadTarget(
|
||||
printCliError("live: internal error: missing thread id");
|
||||
return null;
|
||||
}
|
||||
const resolved = await resolveThreadDataPath(storageRoot, id);
|
||||
const resolved = await resolveThreadRecord(storageRoot, id);
|
||||
if (resolved === null) {
|
||||
printCliError(`thread not found: ${id}`);
|
||||
return null;
|
||||
}
|
||||
return { threadId: id, dataPath: resolved };
|
||||
return {
|
||||
threadId: id,
|
||||
bundleHash: resolved.bundleHash,
|
||||
bundleDir: resolved.bundleDir,
|
||||
threadsJsonPath: join(resolved.bundleDir, "threads.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildLiveWatchTasks(params: {
|
||||
dataPath: string;
|
||||
infoPath: string;
|
||||
storageRoot: string;
|
||||
target: LiveThreadTarget;
|
||||
debug: boolean;
|
||||
dataState: LiveSessionState;
|
||||
dataState: CasLiveState;
|
||||
infoState: InfoLiveState;
|
||||
roleFilter: string | null;
|
||||
cas: CasStore;
|
||||
}): Promise<WatchPumpTask[]> {
|
||||
const { dataPath, infoPath, debug, dataState, infoState, roleFilter, cas } = params;
|
||||
const infoPath = join(
|
||||
params.storageRoot,
|
||||
"logs",
|
||||
params.target.bundleHash,
|
||||
`${params.target.threadId}.info.jsonl`,
|
||||
);
|
||||
|
||||
const tasks: WatchPumpTask[] = [
|
||||
{
|
||||
path: dataPath,
|
||||
pump: () => pumpNewContent(dataPath, dataState, roleFilter, cas),
|
||||
path: params.target.threadsJsonPath,
|
||||
pump: () =>
|
||||
pumpThreadsJson({
|
||||
storageRoot: params.storageRoot,
|
||||
bundleDir: params.target.bundleDir,
|
||||
bundleHash: params.target.bundleHash,
|
||||
threadId: params.target.threadId,
|
||||
state: params.dataState,
|
||||
roleFilter: params.roleFilter,
|
||||
cas: params.cas,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
if (debug && (await pathExists(infoPath))) {
|
||||
if (params.debug && (await pathExists(infoPath))) {
|
||||
tasks.push({
|
||||
path: infoPath,
|
||||
pump: async () => {
|
||||
await pumpNewInfoContent(infoPath, infoState);
|
||||
await pumpNewInfoContent(infoPath, params.infoState);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -376,16 +440,13 @@ export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Prom
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { threadId, dataPath } = target;
|
||||
const roleFilter = parsed.role;
|
||||
const infoPath = join(dirname(dataPath), `${threadId}.info.jsonl`);
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const dataState: LiveSessionState = {
|
||||
sawStart: false,
|
||||
completed: false,
|
||||
carry: "",
|
||||
contentOffset: 0,
|
||||
const dataState: CasLiveState = {
|
||||
printedHashes: new Set<string>(),
|
||||
lastHead: null,
|
||||
completionEmitted: false,
|
||||
};
|
||||
|
||||
const infoState: InfoLiveState = {
|
||||
@@ -400,22 +461,29 @@ export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Prom
|
||||
process.on("SIGINT", onSigInt);
|
||||
|
||||
try {
|
||||
const firstData = await pumpNewContent(dataPath, dataState, roleFilter, cas);
|
||||
if (firstData === 1) {
|
||||
return 1;
|
||||
}
|
||||
await mkdir(dirname(target.threadsJsonPath), { recursive: true });
|
||||
|
||||
const firstData = await pumpThreadsJson({
|
||||
storageRoot,
|
||||
bundleDir: target.bundleDir,
|
||||
bundleHash: target.bundleHash,
|
||||
threadId: target.threadId,
|
||||
state: dataState,
|
||||
roleFilter,
|
||||
cas,
|
||||
});
|
||||
const infoPath = join(storageRoot, "logs", target.bundleHash, `${target.threadId}.info.jsonl`);
|
||||
if (parsed.debug && (await pathExists(infoPath))) {
|
||||
await pumpNewInfoContent(infoPath, infoState);
|
||||
}
|
||||
|
||||
if (firstData === 0 || dataState.completed) {
|
||||
if (firstData === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tasks = await buildLiveWatchTasks({
|
||||
dataPath,
|
||||
infoPath,
|
||||
storageRoot,
|
||||
target,
|
||||
debug: parsed.debug,
|
||||
dataState,
|
||||
infoState,
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
garbageCollectCas,
|
||||
removeThreadEntry,
|
||||
removeThreadHistoryEntries,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
export async function cmdThreadRemove(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
const dir = dirname(dataPath);
|
||||
const infoPath = join(dir, `${threadId}.info.jsonl`);
|
||||
const runningPath = join(dir, `${threadId}.running`);
|
||||
// Always clear both stores: between resolve and delete the worker may finish and
|
||||
// move the thread from threads.json into history; branching only on resolved.source
|
||||
// would skip history removal and leave a dangling row.
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
const hist = await removeThreadHistoryEntries(resolved.bundleDir, threadId);
|
||||
if (!hist.ok) {
|
||||
return hist;
|
||||
}
|
||||
|
||||
const infoPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.info.jsonl`);
|
||||
const runningPath = join(storageRoot, "logs", resolved.bundleHash, `${threadId}.running`);
|
||||
|
||||
await unlink(dataPath);
|
||||
await unlink(infoPath).catch(() => {});
|
||||
await unlink(runningPath).catch(() => {});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export async function cmdRun(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
prompt: string,
|
||||
maxRounds: number,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
if (!nameOk.ok) {
|
||||
@@ -41,7 +40,7 @@ export async function cmdRun(
|
||||
threadId,
|
||||
workflowName: name,
|
||||
prompt,
|
||||
options: { maxRounds, depth: 0 },
|
||||
options: { depth: 0 },
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
|
||||
@@ -1,19 +1,74 @@
|
||||
import { createCasStore, getContentMerklePayload, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import { FORK_BRANCH_ROLE, walkStateFramesNewestFirst } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||
import { resolveThreadDataPath } from "../../thread-scan.js";
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
async function readParentStateFromStartNode(
|
||||
cas: { get(hash: string): Promise<string | null> },
|
||||
startHash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await cas.get(startHash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
return parsed.node.payload.parentState;
|
||||
}
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const parentState = await readParentStateFromStartNode(cas, resolved.start);
|
||||
|
||||
const steps: Array<{
|
||||
role: string;
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
content: string;
|
||||
childThread: string | null;
|
||||
}> = [];
|
||||
for (const fr of chronological) {
|
||||
if (fr.payload.role === END || fr.payload.role === FORK_BRANCH_ROLE) {
|
||||
continue;
|
||||
}
|
||||
const payloadText = await getContentMerklePayload(cas, fr.payload.content);
|
||||
steps.push({
|
||||
role: fr.payload.role,
|
||||
hash: fr.hash,
|
||||
timestamp: fr.payload.timestamp,
|
||||
content:
|
||||
payloadText !== null
|
||||
? payloadText
|
||||
: `(content not in CAS; contentHash=${fr.payload.content})`,
|
||||
childThread: fr.payload.childThread,
|
||||
});
|
||||
}
|
||||
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||
|
||||
const payload = {
|
||||
threadId: resolved.threadId,
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
parentState,
|
||||
source: resolved.source,
|
||||
steps,
|
||||
};
|
||||
|
||||
return ok(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function cmdAdd(
|
||||
return validated;
|
||||
}
|
||||
|
||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||
const extracted = await extractBundleExports(resolvedPath);
|
||||
if (!extracted.ok) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
type FlagOk = { kind: "prompt"; value: string } | { kind: "max-rounds"; value: number };
|
||||
|
||||
function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | null {
|
||||
function parseFlagAt(
|
||||
argv: string[],
|
||||
index: number,
|
||||
): Result<{ kind: "prompt"; value: string }, string> | null {
|
||||
const flag = argv[index];
|
||||
if (flag === "--prompt") {
|
||||
const value = argv[index + 1];
|
||||
@@ -17,24 +17,12 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
|
||||
}
|
||||
return ok({ kind: "prompt", value });
|
||||
}
|
||||
if (flag === "--max-rounds") {
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined) {
|
||||
return err("missing value for --max-rounds");
|
||||
}
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
return err("--max-rounds must be a non-negative integer");
|
||||
}
|
||||
return ok({ kind: "max-rounds", value: n });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
let name: string | undefined;
|
||||
let prompt = "";
|
||||
let maxRounds = 5;
|
||||
|
||||
let i = 0;
|
||||
const first = argv[0];
|
||||
@@ -54,12 +42,7 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
}
|
||||
|
||||
const flag = parsed.value;
|
||||
if (flag.kind === "prompt") {
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
maxRounds = flag.value;
|
||||
prompt = flag.value;
|
||||
i += 2;
|
||||
}
|
||||
|
||||
@@ -67,5 +50,5 @@ export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
return err("run requires <name>");
|
||||
}
|
||||
|
||||
return ok({ name, prompt, maxRounds });
|
||||
return ok({ name, prompt });
|
||||
}
|
||||
|
||||
@@ -54,8 +54,9 @@ function formatSkillCli(): string {
|
||||
const commandSections: string[] = [];
|
||||
for (const group of groups) {
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const namePart = cmd.name === "" ? "" : ` ${cmd.name}`;
|
||||
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||
return `| \`${group.name}${namePart}\` | ${args} | ${cmd.description} |`;
|
||||
});
|
||||
commandSections.push(
|
||||
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||
@@ -70,8 +71,8 @@ function formatSkillCli(): string {
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM bundle (\`.esm.js\`) that exports \`run\` and \`descriptor\`. Identified by name and XXH64 hash. |
|
||||
| **Bundle** | The physical \`.esm.js\` file stored in the bundles directory. Immutable once written. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. Persists state as JSONL files. |
|
||||
| **CAS** | Content-Addressable Storage. Per-thread key-value store keyed by content hash. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. CAS state chain; \`threads.json\` for active; \`history/*.jsonl\` when done; \`.info.jsonl\` for debug logs. |
|
||||
| **CAS** | Global content-addressable blob store (\`cas/\`), keyed by hash. |
|
||||
| **Registry** | \`workflow.yaml\` — maps workflow names to their current and historical bundle hashes. |
|
||||
|
||||
## Commands
|
||||
@@ -85,6 +86,12 @@ ${commandSections.join("\n\n")}
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### connect
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`connect\` | \`[--name NAME] [--gateway URL]\` | Connect to workflow gateway via WebSocket. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
|
||||
@@ -92,6 +99,15 @@ ${commandSections.join("\n\n")}
|
||||
3. \`uncaged-workflow live --latest\` — attach and watch output
|
||||
4. \`uncaged-workflow thread show <thread-id>\` — inspect completed thread
|
||||
|
||||
## Thread Status
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| \`running\` | Worker process is alive (\`.running\` marker + live PID) |
|
||||
| \`active\` | In \`threads.json\` but not currently running (paused or waiting) |
|
||||
| \`completed\` | Finished with \`returnCode === 0\` (has \`__end__\` frame in CAS) |
|
||||
| \`failed\` | Finished with non-zero return code, or worker crashed (dead PID / no ctl) |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
@@ -103,7 +119,9 @@ ${commandSections.join("\n\n")}
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||
| \`WORKFLOW_STORAGE_ROOT\` | Override the default storage directory for all workflow data |
|
||||
| \`UNCAGED_WORKFLOW_STORAGE_ROOT\` | Same as above (takes priority) |
|
||||
| \`WORKFLOW_LLM_API_KEY\` | API key for LLM calls during workflow execution |
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -165,32 +183,63 @@ How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||
|
||||
\`\`\`typescript
|
||||
// Required exports
|
||||
// Required named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowRun;
|
||||
export const run: WorkflowFn;
|
||||
\`\`\`
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Defines the workflow's metadata and role sequence:
|
||||
Serialized metadata for the registry. Every role must include both \`description\` and \`schema\` (JSON Schema object). The graph uses an edges array where each edge has \`from\`, \`to\`, and \`condition\`.
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowDescriptor = {
|
||||
name: string; // verb-first kebab-case, e.g. "solve-issue"
|
||||
description: string; // one-line summary
|
||||
roles: string[]; // ordered role names, e.g. ["planner", "coder", "reviewer"]
|
||||
description: string;
|
||||
roles: Record<string, {
|
||||
description: string;
|
||||
schema: object; // JSON Schema — use z.toJSONSchema(zodSchema) to generate
|
||||
}>;
|
||||
graph: {
|
||||
edges: Array<{
|
||||
from: string; // role name, or "__start__"
|
||||
to: string; // role name, or "__end__"
|
||||
condition: string; // e.g. "FALLBACK"
|
||||
conditionDescription?: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
**descriptor is static data** — it is read at \`workflow add\` (register) time via \`import()\`. It must NOT trigger any side effects or read environment variables.
|
||||
|
||||
The main function that creates and returns a moderator:
|
||||
## WorkflowFn
|
||||
|
||||
Async generator from \`createWorkflow(definition, binding)\` (**@uncaged/workflow-runtime**) — yields each role output until the workflow completes.
|
||||
|
||||
## ModeratorTable
|
||||
|
||||
Declarative routing table. Transitions use the \`role\` field (not \`next\`):
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||
import { START, END, type ModeratorTable } from "@uncaged/workflow-runtime";
|
||||
|
||||
const table: ModeratorTable<MyMeta> = {
|
||||
[START]: [{ condition: "FALLBACK", role: "firstRole" }],
|
||||
firstRole: [{ condition: "FALLBACK", role: END }],
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||
## AdapterFn / AdapterBinding
|
||||
|
||||
The adapter receives a system prompt and Zod schema, returns a \`RoleFn<T>\` that produces typed meta:
|
||||
|
||||
\`\`\`typescript
|
||||
type AdapterFn = <T>(prompt: string, schema: ZodType<T>) => RoleFn<T>;
|
||||
type AdapterBinding = {
|
||||
adapter: AdapterFn;
|
||||
overrides: Partial<Record<string, AdapterFn>> | null;
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## Role Definition
|
||||
|
||||
@@ -200,9 +249,7 @@ Each role has:
|
||||
|-------|------|---------|
|
||||
| \`description\` | string | What the role does |
|
||||
| \`systemPrompt\` | string | System prompt for the agent |
|
||||
| \`extractPrompt\` | string | Instruction for extracting structured meta |
|
||||
| \`schema\` | ZodSchema | Validates the extracted meta |
|
||||
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
|
||||
| \`schema\` | ZodSchema | Validates meta; annotate CAS hash strings with \`.meta({ casRef: true })\` for DAG linking |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -210,15 +257,16 @@ Each role has:
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + moderator + descriptor)
|
||||
# 2. Write your template (roles + ModeratorTable + definition)
|
||||
# 3. Write entry file (workflows/*-entry.ts) with adapter binding + descriptor
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
# 4. Build the ESM bundle
|
||||
bun run bundle # uses scripts/bundle.ts
|
||||
|
||||
# 4. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||
# 5. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow-entry.esm.js
|
||||
|
||||
# 5. Test
|
||||
# 6. Test
|
||||
uncaged-workflow run my-workflow --prompt "test task"
|
||||
uncaged-workflow live --latest
|
||||
\`\`\`
|
||||
@@ -226,5 +274,69 @@ uncaged-workflow live --latest
|
||||
## Versioning
|
||||
|
||||
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
### Lazy initialization is mandatory
|
||||
|
||||
The bundle is \`import()\`-ed at register time (\`workflow add\`) to read the descriptor. At that point, no runtime env vars (API keys, etc.) are available.
|
||||
|
||||
**Never read env at module top-level.** Wrap provider/adapter creation in a lazy closure:
|
||||
|
||||
\`\`\`typescript
|
||||
// ❌ WRONG — breaks register
|
||||
const provider = { apiKey: process.env.MY_KEY! };
|
||||
const adapter = createAdapter(provider);
|
||||
|
||||
// ✅ CORRECT — only reads env when run() is called
|
||||
function createLazyAdapter(): AdapterFn {
|
||||
let cached: Provider | null = null;
|
||||
return (prompt, schema) => {
|
||||
return async (ctx, runtime) => {
|
||||
if (!cached) cached = { apiKey: process.env.MY_KEY! };
|
||||
// ... use cached provider
|
||||
};
|
||||
};
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Agent CLI paths: use env() with absolute path defaults
|
||||
|
||||
Every env var in a bundle must have a sensible default — bundles must run without any env vars set. Use \`env(name, fallback)\` from \`@uncaged/workflow-util\`.
|
||||
|
||||
Discover the correct CLI path yourself (e.g. \`which cursor-agent\`, \`which hermes\`) and hardcode it as the fallback:
|
||||
|
||||
\`\`\`typescript
|
||||
import { env } from "@uncaged/workflow-util";
|
||||
|
||||
// ❌ WRONG — requireEnv and optionalEnv no longer exist
|
||||
const adapter = createCursorAgent({
|
||||
command: requireEnv("WORKFLOW_CURSOR_COMMAND", "set it"),
|
||||
...
|
||||
});
|
||||
|
||||
// ✅ CORRECT — env var is an override, fallback is the discovered absolute path
|
||||
const adapter = createCursorAgent({
|
||||
command: env("WORKFLOW_CURSOR_COMMAND", "/home/you/.local/bin/cursor-agent"),
|
||||
model: env("WORKFLOW_CURSOR_MODEL", "auto"),
|
||||
timeout: Number(env("WORKFLOW_CURSOR_TIMEOUT", "300000")),
|
||||
...
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Bundle import restrictions
|
||||
|
||||
The bundle validator only allows these import specifiers:
|
||||
- Node built-ins (\`node:fs\`, \`node:path\`, etc.)
|
||||
|
||||
All other dependencies — including \`@uncaged/workflow-*\` packages, zod, and any third-party code — must be bundled into the \`.esm.js\` file. Bundles are fully self-contained: same Node/Bun version = same behavior.
|
||||
|
||||
### No default exports
|
||||
|
||||
The engine only reads named exports \`run\` and \`descriptor\`. Using \`export default\` will cause registration to fail silently.
|
||||
|
||||
### Single-file ESM
|
||||
|
||||
The bundle must be a single \`.esm.js\` file. No dynamic \`import()\` inside the bundle — it breaks hash verification and the loader sandbox.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,90 @@
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createCasStore, parseCasThreadNode } from "@uncaged/workflow-cas";
|
||||
import {
|
||||
readThreadsIndex,
|
||||
type ThreadHistoryEntry,
|
||||
type ThreadIndex,
|
||||
walkStateFramesNewestFirst,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { readWorkerCtl } from "./worker-spawn.js";
|
||||
|
||||
function parseFirstJsonLineObject(text: string): Record<string, unknown> | null {
|
||||
const firstLine = text.split("\n")[0];
|
||||
if (firstLine === undefined || firstLine.trim() === "") {
|
||||
async function readWorkflowNameFromStartHash(
|
||||
storageRoot: string,
|
||||
startHash: string,
|
||||
): Promise<string | null> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const yamlText = await cas.get(startHash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine) as unknown;
|
||||
} catch {
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return null;
|
||||
return parsed.node.payload.name;
|
||||
}
|
||||
|
||||
async function listBundleHashDirs(storageRoot: string): Promise<string[]> {
|
||||
const bundlesRoot = join(storageRoot, "bundles");
|
||||
if (!(await pathExists(bundlesRoot))) {
|
||||
return [];
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
const names = await readdir(bundlesRoot);
|
||||
const out: string[] = [];
|
||||
for (const name of names) {
|
||||
const p = join(bundlesRoot, name);
|
||||
try {
|
||||
const st = await stat(p);
|
||||
if (st.isDirectory()) {
|
||||
out.push(name);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
|
||||
async function parseHistoryFile(path: string): Promise<ThreadHistoryEntry[]> {
|
||||
const text = await readTextFileIfExists(path);
|
||||
if (text === null) {
|
||||
return [];
|
||||
}
|
||||
const out: ThreadHistoryEntry[] = [];
|
||||
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") {
|
||||
continue;
|
||||
}
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const threadId = rec.threadId;
|
||||
const head = rec.head;
|
||||
const start = rec.start;
|
||||
const completedAt = rec.completedAt;
|
||||
if (
|
||||
typeof threadId !== "string" ||
|
||||
typeof head !== "string" ||
|
||||
typeof start !== "string" ||
|
||||
typeof completedAt !== "number"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out.push({ threadId, head, start, completedAt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type RunningThreadRow = {
|
||||
@@ -30,32 +97,173 @@ export type HistoricalThreadRow = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
workflowName: string | null;
|
||||
/** Active entry from `threads.json` vs completed line from `history/*.jsonl`. */
|
||||
source: "active" | "history";
|
||||
/** `updatedAt` for active threads; `completedAt` for history (ms since epoch). */
|
||||
activityTs: number;
|
||||
/** Current CAS head (`threads.json` / history row). */
|
||||
head: string;
|
||||
};
|
||||
|
||||
async function readThreadStartTimestampMs(dataPath: string): Promise<number | null> {
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return null;
|
||||
export type ResolvedThreadRecord = {
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
bundleDir: string;
|
||||
head: string;
|
||||
start: string;
|
||||
source: "active" | "history";
|
||||
};
|
||||
|
||||
/** Resolve a thread via `threads.json` (active) or `history/*.jsonl` (completed). */
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: scans all bundle dirs for thread id
|
||||
export async function resolveThreadRecord(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<ResolvedThreadRecord | null> {
|
||||
const hashes = await listBundleHashDirs(storageRoot);
|
||||
for (const bundleHash of hashes) {
|
||||
const bundleDir = join(storageRoot, "bundles", bundleHash);
|
||||
let index: ThreadIndex;
|
||||
try {
|
||||
index = await readThreadsIndex(bundleDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const active = index[threadId];
|
||||
if (active !== undefined) {
|
||||
return {
|
||||
threadId,
|
||||
bundleHash,
|
||||
bundleDir,
|
||||
head: active.head,
|
||||
start: active.start,
|
||||
source: "active",
|
||||
};
|
||||
}
|
||||
}
|
||||
const parsed = parseFirstJsonLineObject(text);
|
||||
if (parsed === null) {
|
||||
return null;
|
||||
|
||||
for (const bundleHash of hashes) {
|
||||
const bundleDir = join(storageRoot, "bundles", bundleHash);
|
||||
const histDir = join(bundleDir, "history");
|
||||
if (!(await pathExists(histDir))) {
|
||||
continue;
|
||||
}
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(histDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of files) {
|
||||
if (!name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
const entries = await parseHistoryFile(join(histDir, name));
|
||||
for (const e of entries) {
|
||||
if (e.threadId === threadId) {
|
||||
return {
|
||||
threadId,
|
||||
bundleHash,
|
||||
bundleDir,
|
||||
head: e.head,
|
||||
start: e.start,
|
||||
source: "history",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const ts = parsed.timestamp;
|
||||
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | null> {
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return null;
|
||||
export type ThreadHeadTerminal =
|
||||
| { kind: "non-terminal" }
|
||||
| { kind: "terminal"; returnCode: number };
|
||||
|
||||
/** True when the newest frame at `headHash` is `__end__` (workflow finished in CAS). */
|
||||
export async function readThreadTerminalFromHead(
|
||||
storageRoot: string,
|
||||
headHash: string,
|
||||
): Promise<ThreadHeadTerminal> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, headHash);
|
||||
const newest = frames[0];
|
||||
if (newest === undefined) {
|
||||
return { kind: "non-terminal" };
|
||||
}
|
||||
const parsed = parseFirstJsonLineObject(text);
|
||||
if (parsed === null) {
|
||||
return null;
|
||||
if (newest.payload.role !== END) {
|
||||
return { kind: "non-terminal" };
|
||||
}
|
||||
const name = parsed.name;
|
||||
return typeof name === "string" ? name : null;
|
||||
const rc = newest.payload.meta.returnCode;
|
||||
if (typeof rc !== "number") {
|
||||
return { kind: "terminal", returnCode: 1 };
|
||||
}
|
||||
return { kind: "terminal", returnCode: rc };
|
||||
}
|
||||
|
||||
export type ThreadListStatus = "running" | "active" | "completed" | "failed";
|
||||
|
||||
/** Combines `.running` marker with CAS head: stale markers do not imply `running`. */
|
||||
export async function resolveThreadListStatus(
|
||||
storageRoot: string,
|
||||
row: HistoricalThreadRow,
|
||||
runningMarkerPresent: boolean,
|
||||
): Promise<ThreadListStatus> {
|
||||
const terminal = await readThreadTerminalFromHead(storageRoot, row.head);
|
||||
if (terminal.kind === "terminal") {
|
||||
return terminal.returnCode !== 0 ? "failed" : "completed";
|
||||
}
|
||||
if (row.source === "history") {
|
||||
return "completed";
|
||||
}
|
||||
if (runningMarkerPresent) {
|
||||
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
|
||||
if (ctlResult.ok) {
|
||||
try {
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
return "running";
|
||||
} catch {
|
||||
// Worker PID is dead but .running marker remains — crashed thread
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
return "running";
|
||||
}
|
||||
// No .running marker + no __end__ + source "active" → check if worker is dead (crashed)
|
||||
const ctlResult = await readWorkerCtl(storageRoot, row.hash);
|
||||
if (!ctlResult.ok) {
|
||||
// No ctl file means worker never registered or was already cleaned up — dead thread
|
||||
return "failed";
|
||||
}
|
||||
try {
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
} catch {
|
||||
// Worker PID is dead, thread never finished — crashed
|
||||
return "failed";
|
||||
}
|
||||
return "active";
|
||||
}
|
||||
|
||||
async function appendRunningThreadRowIfLive(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
threadId: string,
|
||||
out: RunningThreadRow[],
|
||||
): Promise<void> {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved !== null && resolved.bundleHash !== hash) {
|
||||
return;
|
||||
}
|
||||
if (resolved !== null) {
|
||||
const terminal = await readThreadTerminalFromHead(storageRoot, resolved.head);
|
||||
if (terminal.kind === "terminal") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const workflowName =
|
||||
resolved !== null ? await readWorkflowNameFromStartHash(storageRoot, resolved.start) : null;
|
||||
out.push({ threadId, hash, workflowName });
|
||||
}
|
||||
|
||||
/** Threads currently executing — identified via `<threadId>.running` markers. */
|
||||
@@ -82,9 +290,7 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
|
||||
continue;
|
||||
}
|
||||
const threadId = fileName.slice(0, -".running".length);
|
||||
const dataPath = join(dir, `${threadId}.data.jsonl`);
|
||||
const workflowName = await readWorkflowNameFromDataJsonl(dataPath);
|
||||
out.push({ threadId, hash, workflowName });
|
||||
await appendRunningThreadRowIfLive(storageRoot, hash, threadId, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,41 +304,84 @@ export async function listRunningThreads(storageRoot: string): Promise<RunningTh
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical threads discovered via `*.data.jsonl`.
|
||||
* When `workflowNameFilter` is non-null, only threads whose start record `name` matches are returned.
|
||||
* Threads discovered via `threads.json` (active) and `history/*.jsonl` (completed).
|
||||
* When `workflowNameFilter` is non-null, only threads whose StartNode `name` matches are returned.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: merges active index + partitioned history
|
||||
export async function listHistoricalThreads(
|
||||
storageRoot: string,
|
||||
workflowNameFilter: string | null,
|
||||
): Promise<HistoricalThreadRow[]> {
|
||||
const logsRoot = join(storageRoot, "logs");
|
||||
if (!(await pathExists(logsRoot))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hashes = await readdir(logsRoot);
|
||||
const hashes = await listBundleHashDirs(storageRoot);
|
||||
const seen = new Set<string>();
|
||||
const out: HistoricalThreadRow[] = [];
|
||||
|
||||
for (const hash of hashes) {
|
||||
const dir = join(logsRoot, hash);
|
||||
let entries: string[];
|
||||
for (const bundleHash of hashes) {
|
||||
const bundleDir = join(storageRoot, "bundles", bundleHash);
|
||||
let index: ThreadIndex;
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
index = await readThreadsIndex(bundleDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const fileName of entries) {
|
||||
if (!fileName.endsWith(".data.jsonl")) {
|
||||
for (const threadId of Object.keys(index)) {
|
||||
const key = `${bundleHash}/${threadId}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const threadId = fileName.slice(0, -".data.jsonl".length);
|
||||
const dataPath = join(dir, fileName);
|
||||
const workflowName = await readWorkflowNameFromDataJsonl(dataPath);
|
||||
seen.add(key);
|
||||
const entry = index[threadId];
|
||||
if (entry === undefined) {
|
||||
continue;
|
||||
}
|
||||
const workflowName = await readWorkflowNameFromStartHash(storageRoot, entry.start);
|
||||
if (workflowNameFilter !== null && workflowName !== workflowNameFilter) {
|
||||
continue;
|
||||
}
|
||||
out.push({ threadId, hash, workflowName });
|
||||
out.push({
|
||||
threadId,
|
||||
hash: bundleHash,
|
||||
workflowName,
|
||||
source: "active",
|
||||
activityTs: entry.updatedAt,
|
||||
head: entry.head,
|
||||
});
|
||||
}
|
||||
|
||||
const histDir = join(bundleDir, "history");
|
||||
if (!(await pathExists(histDir))) {
|
||||
continue;
|
||||
}
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(histDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of files) {
|
||||
if (!name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
const entries = await parseHistoryFile(join(histDir, name));
|
||||
for (const e of entries) {
|
||||
const key = `${bundleHash}/${e.threadId}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
const workflowName = await readWorkflowNameFromStartHash(storageRoot, e.start);
|
||||
if (workflowNameFilter !== null && workflowName !== workflowNameFilter) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
threadId: e.threadId,
|
||||
hash: bundleHash,
|
||||
workflowName,
|
||||
source: "history",
|
||||
activityTs: e.completedAt,
|
||||
head: e.head,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,64 +394,93 @@ export async function listHistoricalThreads(
|
||||
return out;
|
||||
}
|
||||
|
||||
export type LatestThreadTarget = {
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
bundleDir: string;
|
||||
threadsJsonPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the thread whose `.data.jsonl` is newest by start-record `timestamp`,
|
||||
* falling back to file `mtime` when the timestamp is missing.
|
||||
* Tie-breaker: larger `mtime` wins when start timestamps are equal.
|
||||
* Picks the newest thread by StartNode timestamp approximation (`updatedAt` active,
|
||||
* else `completedAt` history), falling back to lexical thread id order.
|
||||
*/
|
||||
export async function findLatestThreadDataPath(
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: compares active heads vs history tails
|
||||
export async function findLatestThreadBundleTarget(
|
||||
storageRoot: string,
|
||||
): Promise<{ threadId: string; dataPath: string } | null> {
|
||||
const threads = await listHistoricalThreads(storageRoot, null);
|
||||
if (threads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
): Promise<LatestThreadTarget | null> {
|
||||
const hashes = await listBundleHashDirs(storageRoot);
|
||||
|
||||
let best: {
|
||||
threadId: string;
|
||||
dataPath: string;
|
||||
primary: number;
|
||||
secondary: number;
|
||||
bundleHash: string;
|
||||
bundleDir: string;
|
||||
ts: number;
|
||||
} | null = null;
|
||||
|
||||
for (const t of threads) {
|
||||
const dataPath = join(storageRoot, "logs", t.hash, `${t.threadId}.data.jsonl`);
|
||||
let mtimeMs = 0;
|
||||
for (const bundleHash of hashes) {
|
||||
const bundleDir = join(storageRoot, "bundles", bundleHash);
|
||||
let index: ThreadIndex;
|
||||
try {
|
||||
const st = await stat(dataPath);
|
||||
mtimeMs = st.mtimeMs;
|
||||
index = await readThreadsIndex(bundleDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const startTs = await readThreadStartTimestampMs(dataPath);
|
||||
const primary = startTs !== null ? startTs : mtimeMs;
|
||||
const secondary = mtimeMs;
|
||||
if (
|
||||
best === null ||
|
||||
primary > best.primary ||
|
||||
(primary === best.primary && secondary > best.secondary)
|
||||
) {
|
||||
best = { threadId: t.threadId, dataPath, primary, secondary };
|
||||
for (const threadId of Object.keys(index)) {
|
||||
const ent = index[threadId];
|
||||
if (ent === undefined) {
|
||||
continue;
|
||||
}
|
||||
const ts = ent.updatedAt;
|
||||
const cand = { threadId, bundleHash, bundleDir, ts };
|
||||
if (
|
||||
best === null ||
|
||||
cand.ts > best.ts ||
|
||||
(cand.ts === best.ts &&
|
||||
`${cand.bundleHash}/${cand.threadId}` > `${best.bundleHash}/${best.threadId}`)
|
||||
) {
|
||||
best = cand;
|
||||
}
|
||||
}
|
||||
|
||||
const histDir = join(bundleDir, "history");
|
||||
if (!(await pathExists(histDir))) {
|
||||
continue;
|
||||
}
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(histDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const name of files) {
|
||||
if (!name.endsWith(".jsonl")) {
|
||||
continue;
|
||||
}
|
||||
const entries = await parseHistoryFile(join(histDir, name));
|
||||
for (const e of entries) {
|
||||
const ts = e.completedAt;
|
||||
const cand = { threadId: e.threadId, bundleHash, bundleDir, ts };
|
||||
if (
|
||||
best === null ||
|
||||
cand.ts > best.ts ||
|
||||
(cand.ts === best.ts &&
|
||||
`${cand.bundleHash}/${cand.threadId}` > `${best.bundleHash}/${best.threadId}`)
|
||||
) {
|
||||
best = cand;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best === null ? null : { threadId: best.threadId, dataPath: best.dataPath };
|
||||
}
|
||||
|
||||
export async function resolveThreadDataPath(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<string | null> {
|
||||
const logsRoot = join(storageRoot, "logs");
|
||||
if (!(await pathExists(logsRoot))) {
|
||||
if (best === null) {
|
||||
return null;
|
||||
}
|
||||
const hashes = await readdir(logsRoot);
|
||||
for (const hash of hashes) {
|
||||
const candidate = join(logsRoot, hash, `${threadId}.data.jsonl`);
|
||||
if (await pathExists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
return {
|
||||
threadId: best.threadId,
|
||||
bundleHash: best.bundleHash,
|
||||
bundleDir: best.bundleDir,
|
||||
threadsJsonPath: join(best.bundleDir, "threads.json"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getWorkerHostScriptPath } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { readThreadTerminalFromHead, resolveThreadRecord } from "./thread-scan.js";
|
||||
|
||||
export type WorkerCtl = {
|
||||
pid: number;
|
||||
@@ -269,7 +270,25 @@ export async function resolveRunningHashForThread(
|
||||
if (!(await pathExists(logsRoot))) {
|
||||
return err(`thread not running (no logs dir): ${threadId}`);
|
||||
}
|
||||
const hashes = await readdir(logsRoot);
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved !== null) {
|
||||
const runningPath = join(logsRoot, resolved.bundleHash, `${threadId}.running`);
|
||||
if (!(await pathExists(runningPath))) {
|
||||
return err(`thread not running: ${threadId}`);
|
||||
}
|
||||
const terminal = await readThreadTerminalFromHead(storageRoot, resolved.head);
|
||||
if (terminal.kind === "terminal") {
|
||||
return err(`thread not running: ${threadId}`);
|
||||
}
|
||||
return ok(resolved.bundleHash);
|
||||
}
|
||||
|
||||
let hashes: string[];
|
||||
try {
|
||||
hashes = await readdir(logsRoot);
|
||||
} catch {
|
||||
return err(`thread not running: ${threadId}`);
|
||||
}
|
||||
for (const hash of hashes) {
|
||||
const runningPath = join(logsRoot, hash, `${threadId}.running`);
|
||||
if (await pathExists(runningPath)) {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# @uncaged/workflow-agent-cursor
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- Updated dependencies [f74b482]
|
||||
- Updated dependencies [f74b482]
|
||||
- @uncaged/workflow-util@0.5.0-alpha.4
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.4
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.4
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.3
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.3
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.2
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.2
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.1
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
- @uncaged/workflow-util@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.5.0-alpha.0
|
||||
- @uncaged/workflow-cas@0.5.0-alpha.0
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.5
|
||||
- @uncaged/workflow-reactor@0.4.5
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.4
|
||||
- @uncaged/workflow-reactor@0.4.4
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.3
|
||||
- @uncaged/workflow-reactor@0.4.3
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
- @uncaged/workflow-util@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.2
|
||||
- @uncaged/workflow-reactor@0.4.2
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
- @uncaged/workflow-util@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-protocol@0.4.0
|
||||
- @uncaged/workflow-reactor@0.4.0
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
- @uncaged/workflow-util@0.4.0
|
||||
@@ -1,67 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
|
||||
_schema: z.ZodType<T>,
|
||||
_prompt: string,
|
||||
_ctx: ExtractContext,
|
||||
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
|
||||
meta: { workspace: "/tmp" } as unknown as T,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
});
|
||||
const baseConfig = {
|
||||
command: "/usr/local/bin/cursor-agent",
|
||||
model: null as string | null,
|
||||
timeout: 0,
|
||||
workspace: null as string | null,
|
||||
};
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
...baseConfig,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-function extract", () => {
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: null as unknown as ExtractFn,
|
||||
...baseConfig,
|
||||
command: "cursor-agent",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("extract");
|
||||
expect(r.error).toContain("absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
model: null,
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects non-absolute workspace when set", () => {
|
||||
const r = validateCursorAgentConfig({
|
||||
...baseConfig,
|
||||
workspace: "relative/path",
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("workspace");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCursorAgent", () => {
|
||||
test("returns an AgentFn", () => {
|
||||
test("returns an AdapterFn", () => {
|
||||
const agent = createCursorAgent({
|
||||
model: null,
|
||||
timeout: 0,
|
||||
extract: testExtract,
|
||||
...baseConfig,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
test("throws on invalid config at construction", () => {
|
||||
expect(() =>
|
||||
createCursorAgent({
|
||||
model: null,
|
||||
timeout: -1,
|
||||
extract: testExtract,
|
||||
}),
|
||||
).toThrow();
|
||||
test("defers validation to call time (invalid config does not throw at construction)", () => {
|
||||
const agent = createCursorAgent({
|
||||
...baseConfig,
|
||||
timeout: -1,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,32 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"@uncaged/workflow-cas": "workspace:^",
|
||||
"@uncaged/workflow-protocol": "workspace:^",
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util-agent
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.4.3
|
||||
|
||||
packages:
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
zod@4.4.3: {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { putContentNodeWithRefs } from "@uncaged/workflow-cas";
|
||||
import type { ThreadContext, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import type { LogFn } from "@uncaged/workflow-util";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const workspaceSchema = z.object({
|
||||
workspace: z.string().describe("Absolute filesystem path of the project workspace"),
|
||||
});
|
||||
|
||||
function buildExtractionInput(ctx: ThreadContext): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
|
||||
for (const step of ctx.steps) {
|
||||
lines.push("");
|
||||
lines.push(`## Step: ${step.role}`);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Extract the absolute filesystem path of the project workspace where code changes should be made.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function extractWorkspacePath(
|
||||
ctx: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
logger: LogFn,
|
||||
): Promise<string | null> {
|
||||
const input = buildExtractionInput(ctx);
|
||||
const contentHash = await putContentNodeWithRefs(runtime.cas, input, []);
|
||||
|
||||
const result = await runtime.extract(workspaceSchema, contentHash);
|
||||
const workspace = result.meta.workspace.trim();
|
||||
|
||||
if (!workspace.startsWith("/")) {
|
||||
logger("H4PM7RXV", `workspace extraction returned non-absolute path: ${workspace}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger("V3KM8QWP", `extracted workspace: ${workspace}`);
|
||||
return workspace;
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
|
||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||
import * as z from "zod/v4";
|
||||
import type { AdapterFn, AgentFn, WorkflowRuntime } from "@uncaged/workflow-runtime";
|
||||
import { createLogger, type LogFn } from "@uncaged/workflow-util";
|
||||
import {
|
||||
buildThreadInput,
|
||||
createAgentAdapter,
|
||||
type SpawnCliError,
|
||||
spawnCli,
|
||||
} from "@uncaged/workflow-util-agent";
|
||||
|
||||
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";
|
||||
|
||||
const cursorWorkspaceSchema = z.object({
|
||||
workspace: z
|
||||
.string()
|
||||
.describe("Absolute path to the project/repository directory the agent should work in"),
|
||||
});
|
||||
|
||||
function throwCursorSpawnError(error: SpawnCliError): never {
|
||||
if (error.kind === "non_zero_exit") {
|
||||
throw new Error(
|
||||
@@ -33,28 +34,18 @@ function resolveCursorModel(model: string | null): string {
|
||||
return model === null ? "auto" : model;
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from {@link CursorAgentConfig.extract} and prompt from context. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
type CursorAgentOpt = { prompt: string; workspace: string };
|
||||
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
|
||||
return async (ctx) => {
|
||||
const extractCtx: ExtractContext = {
|
||||
...ctx,
|
||||
agentContent: "",
|
||||
};
|
||||
const extracted = await config.extract(
|
||||
cursorWorkspaceSchema,
|
||||
"From the thread context, determine the absolute filesystem path where the project/repository is located.",
|
||||
extractCtx,
|
||||
);
|
||||
const { workspace } = extracted.meta;
|
||||
const fullPrompt = await buildAgentPrompt(ctx);
|
||||
function createCursorAgentFn(
|
||||
config: CursorAgentConfig,
|
||||
modelFlag: string,
|
||||
timeoutMs: number | null,
|
||||
logger: LogFn,
|
||||
): AgentFn<CursorAgentOpt> {
|
||||
return async (ctx, { prompt, workspace }) => {
|
||||
logger("R5HN3YKQ", `cursor-agent workspace: ${workspace}`);
|
||||
const threadInput = await buildThreadInput(ctx);
|
||||
const fullPrompt = `${prompt}\n\n${threadInput}`;
|
||||
const args = [
|
||||
"-p",
|
||||
fullPrompt,
|
||||
@@ -67,7 +58,7 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
"--trust",
|
||||
"--force",
|
||||
];
|
||||
const run = await spawnCli("cursor-agent", args, {
|
||||
const run = await spawnCli(config.command, args, {
|
||||
cwd: workspace,
|
||||
timeoutMs,
|
||||
});
|
||||
@@ -77,3 +68,31 @@ export function createCursorAgent(config: CursorAgentConfig): AgentFn {
|
||||
return run.value;
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `cursor-agent` with workspace from config or extracted from thread context via runtime.extract. */
|
||||
export function createCursorAgent(config: CursorAgentConfig): AdapterFn {
|
||||
const modelFlag = resolveCursorModel(config.model);
|
||||
const timeoutMs = config.timeout > 0 ? config.timeout : null;
|
||||
const logger = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
return createAgentAdapter(
|
||||
createCursorAgentFn(config, modelFlag, timeoutMs, logger),
|
||||
async (ctx, prompt, runtime: WorkflowRuntime) => {
|
||||
const validated = validateCursorAgentConfig(config);
|
||||
if (!validated.ok) {
|
||||
throw new Error(validated.error);
|
||||
}
|
||||
|
||||
const workspace =
|
||||
config.workspace !== null
|
||||
? config.workspace
|
||||
: await extractWorkspacePath(ctx, runtime, logger);
|
||||
if (workspace === null) {
|
||||
throw new Error(
|
||||
"cursor-agent: failed to extract workspace path from context. Ensure the task prompt or previous steps include a project path.",
|
||||
);
|
||||
}
|
||||
return { prompt, workspace };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +1,11 @@
|
||||
import type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type CursorAgentConfig = {
|
||||
/** Absolute path to the cursor-agent CLI binary. */
|
||||
command: string;
|
||||
model: string | null;
|
||||
timeout: number;
|
||||
extract: ExtractFn;
|
||||
/**
|
||||
* When non-null, use this workspace directory for `cursor-agent` instead of resolving it
|
||||
* from the thread via runtime extraction.
|
||||
*/
|
||||
workspace: string | null;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { CursorAgentConfig } from "./types.js";
|
||||
|
||||
export function validateCursorAgentConfig(config: CursorAgentConfig): Result<void, string> {
|
||||
if (typeof config.extract !== "function") {
|
||||
return err("extract must be a function");
|
||||
if (!isAbsolute(config.command)) {
|
||||
return err("command must be an absolute path to the cursor-agent CLI binary");
|
||||
}
|
||||
if (config.timeout < 0) {
|
||||
return err("timeout must be a non-negative number (milliseconds); use 0 for no limit");
|
||||
}
|
||||
if (config.workspace !== null && !isAbsolute(config.workspace)) {
|
||||
return err("workspace must be an absolute filesystem path when set");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util-agent" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# @uncaged/workflow-agent-hermes
|
||||
|
||||
## 0.5.0-alpha.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.4
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.4
|
||||
|
||||
## 0.5.0-alpha.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.3
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.3
|
||||
|
||||
## 0.5.0-alpha.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.2
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.2
|
||||
|
||||
## 0.5.0-alpha.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.1
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.1
|
||||
|
||||
## 0.5.0-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.5.0-alpha.0
|
||||
- @uncaged/workflow-util-agent@0.5.0-alpha.0
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.4.5
|
||||
- @uncaged/workflow-util-agent@0.4.5
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @uncaged/workflow-runtime@0.4.4
|
||||
- @uncaged/workflow-util-agent@0.4.4
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Include src/ in published packages so bun runtime can resolve the 'bun' exports condition.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.3
|
||||
- @uncaged/workflow-util-agent@0.4.3
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix workspace dependency resolution: use workspace:^ so published packages resolve to compatible versions instead of exact (non-existent) versions.
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.2
|
||||
- @uncaged/workflow-util-agent@0.4.2
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Fix package exports for published packages and adopt changesets for version management.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @uncaged/workflow-runtime@0.4.0
|
||||
- @uncaged/workflow-util-agent@0.4.0
|
||||
@@ -4,14 +4,28 @@ import { createHermesAgent, validateHermesAgentConfig } from "../src/index.js";
|
||||
describe("validateHermesAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects non-absolute command", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("absolute path");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects negative timeout", () => {
|
||||
const r = validateHermesAgentConfig({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: -5,
|
||||
});
|
||||
@@ -23,10 +37,11 @@ describe("validateHermesAgentConfig", () => {
|
||||
});
|
||||
|
||||
describe("createHermesAgent", () => {
|
||||
test("returns an AgentFn", () => {
|
||||
test("returns an AdapterFn even with invalid config (validation deferred to call)", () => {
|
||||
const agent = createHermesAgent({
|
||||
command: "/usr/local/bin/hermes",
|
||||
model: null,
|
||||
timeout: null,
|
||||
timeout: -5,
|
||||
});
|
||||
expect(typeof agent).toBe("function");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,28 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-hermes",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.0-alpha.4",
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"package.json"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*"
|
||||
"@uncaged/workflow-runtime": "workspace:^",
|
||||
"@uncaged/workflow-util-agent": "workspace:^"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@uncaged/workflow-runtime':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-runtime
|
||||
'@uncaged/workflow-util-agent':
|
||||
specifier: workspace:*
|
||||
version: link:../workflow-util-agent
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user