Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 26cf51366f | |||
| 81c582ae0e | |||
| 6f000512d2 | |||
| 8f78a00063 | |||
| 6c2a137aef | |||
| 6cd856ca99 | |||
| 064696c558 | |||
| 0f28e9b61a | |||
| 1ea56009a2 | |||
| 6cc2481a16 | |||
| 44018bd17d | |||
| 28c35bb3e0 | |||
| b8b557baf6 | |||
| 727b4bb3ed | |||
| 9bbdfc41bd | |||
| b07f8cf166 | |||
| 1a1e8b3398 | |||
| 39d2a61686 | |||
| bf0bc47a3f | |||
| 2cffaad127 | |||
| 9a3daac657 | |||
| b8f9ffcb59 | |||
| a7171f05f6 | |||
| b53667a2aa | |||
| 5b60fa6454 | |||
| 2c0e744ebf | |||
| ae16f09688 | |||
| 73a3638ad9 | |||
| 7b0260cedd | |||
| 61fc1cfe1b | |||
| 6b1e728700 | |||
| dedab62c49 | |||
| a44f1f34a8 | |||
| 8ff6f7e778 | |||
| e04e75bdee | |||
| c65c29c1b5 | |||
| cc3f2b576c | |||
| 884ff85205 | |||
| a11cc62a81 | |||
| 34f5e655d1 | |||
| 44fb0694aa | |||
| cdcaff15ab | |||
| 402479ddef | |||
| a28dd3050e | |||
| ce0d0a962c | |||
| 46b552ec01 | |||
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 | |||
| 520b17b351 | |||
| 085cdcd3f4 | |||
| a8c1c158d6 | |||
| 83649fd836 | |||
| a5c09adae6 | |||
| 9e6cd9d615 | |||
| 1f1128ff4a | |||
| aa01283ce1 | |||
| f81e2a8aac | |||
| 2b38e583be | |||
| 4ff1394224 | |||
| 2bbe5a3d0e | |||
| a4237c0462 | |||
| 321e5b1379 | |||
| 7c3e14c473 | |||
| aecce595e8 | |||
| cf17dedac3 | |||
| 661fdbb263 | |||
| 201abf98ce | |||
| 665965fd01 | |||
| 6a99f84025 | |||
| f61474bec0 | |||
| 9bdb18afd0 | |||
| 2af299f3ce | |||
| d9f79c60a1 | |||
| 485bfcb0b6 | |||
| a47ed06ea5 | |||
| 2ef004eecf | |||
| 2616259a0f | |||
| 23b2c3b47d | |||
| 7d3954097d | |||
| 4a925b98af | |||
| bfea771a52 | |||
| 5e411a1f19 | |||
| 21238f7825 | |||
| 6b3aa4ce35 | |||
| f042c9d640 | |||
| 66bca9ef03 | |||
| 309af39447 | |||
| 86a422f7e2 | |||
| 648f0c6dec | |||
| 8456a8337b | |||
| 9c8b98a551 | |||
| c3272be760 | |||
| c44b773a86 | |||
| 2776f8e419 | |||
| 7b0e256c13 | |||
| c663ba9e9c | |||
| 71b413f20c | |||
| 61be1c662a | |||
| 84e8d70da4 | |||
| 8976f4cf3b | |||
| 07730dd24c | |||
| 4eff4d2370 | |||
| 1d6da18b18 | |||
| c342ff3737 | |||
| 8fe26417cf | |||
| 990200230b | |||
| 4eaefd9974 | |||
| 1a685583bd | |||
| 19769efea6 | |||
| 7f64541c5b | |||
| 43a6600378 | |||
| 74e3f5434c | |||
| 220c9c5224 | |||
| cae59b589e | |||
| 703ac9dfcc | |||
| 2df8accf2f | |||
| b5cc0db17e | |||
| 6196e0974a | |||
| 410e9e6d9b | |||
| 84de74721d | |||
| 4403532f35 |
@@ -3,3 +3,5 @@ dist/
|
||||
bun.lock
|
||||
*.tgz
|
||||
tsconfig.tsbuildinfo
|
||||
.npmrc
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
**@uncaged/workflow** is a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier.
|
||||
This monorepo implements a workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file with an XXH64 hash as its version identifier. Shared types live in `@uncaged/workflow-protocol`; bundle authors typically depend on `@uncaged/workflow-runtime`.
|
||||
|
||||
### Key Terms
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|---------|-----------|
|
||||
| **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. |
|
||||
|
||||
@@ -19,14 +19,27 @@
|
||||
```
|
||||
workflow/
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, hash, ULID, JSONL, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (uncaged-workflow command)
|
||||
workflow-protocol/ # @uncaged/workflow-protocol — shared types + Result
|
||||
workflow-runtime/ # @uncaged/workflow-runtime — createWorkflow, type re-exports
|
||||
workflow-util/ # @uncaged/workflow-util — Base32, ULID, logger, storage paths, refs helpers
|
||||
workflow-reactor/ # @uncaged/workflow-reactor — LLM fn + thread reactor (tool calls)
|
||||
workflow-cas/ # @uncaged/workflow-cas — CAS store, hash, Merkle
|
||||
workflow-register/ # @uncaged/workflow-register — bundle validation, registry YAML, model resolution
|
||||
workflow-execute/ # @uncaged/workflow-execute — engine, extract, fork, GC, workflowAsAgent
|
||||
cli-workflow/ # @uncaged/cli-workflow — uncaged-workflow CLI
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — buildAgentPrompt, spawnCli
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue
|
||||
workflow-dashboard/ # @uncaged/workflow-dashboard — React dashboard (private app)
|
||||
docs/ # RFCs, conventions
|
||||
biome.json # root Biome config
|
||||
tsconfig.json # root TypeScript config
|
||||
```
|
||||
|
||||
- `workflow` is the core; `cli-workflow` depends on it
|
||||
- Execution stack layers: `workflow-protocol` → (`workflow-runtime`, `workflow-util`, `workflow-reactor`) → (`workflow-cas`, `workflow-register`) → `workflow-execute` → `cli-workflow`
|
||||
- Packages use `workspace:*` protocol
|
||||
|
||||
## Language & Paradigm
|
||||
@@ -97,6 +110,36 @@ type WorkflowEntry = {
|
||||
|
||||
Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`.
|
||||
|
||||
### Folder Module Discipline
|
||||
|
||||
Every folder under `src/` is a **module boundary**. Four rules:
|
||||
|
||||
| # | Rule | Rationale |
|
||||
|---|------|-----------|
|
||||
| 1 | **Every folder exports via `index.ts`** | Single entry point for the module |
|
||||
| 2 | **Types live in `types.ts`** | Each folder's type definitions go in `<folder>/types.ts`, not scattered across files |
|
||||
| 3 | **Single export source** | Only `index.ts` may re-export. No file may re-export from another module's internals. Cross-module imports must go through `index.ts` — never reach past it to import a specific file |
|
||||
| 4 | **`index.ts` is pure re-exports** | No type definitions, no function implementations — only `export { ... } from` statements |
|
||||
|
||||
```typescript
|
||||
// ✅ Good — import through module boundary
|
||||
import { createCasStore } from "../cas/index.js";
|
||||
import type { CasStore } from "../cas/index.js";
|
||||
|
||||
// ❌ Bad — reaching past index.ts
|
||||
import { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — re-exporting from non-index file
|
||||
// in engine/engine.ts:
|
||||
export { createCasStore } from "../cas/cas.js";
|
||||
|
||||
// ❌ Bad — types defined in index.ts
|
||||
// in cas/index.ts:
|
||||
export type CasStore = { ... }; // should be in cas/types.ts
|
||||
```
|
||||
|
||||
**Exception**: The package-level `src/index.ts` is the public API surface and re-exports from folder `index.ts` files. Files that remain at `src/` root (e.g. `types.ts`, `workflow-as-agent.ts`) are not inside a folder module and follow normal rules.
|
||||
|
||||
## Naming
|
||||
|
||||
| Type | Style | Example |
|
||||
@@ -137,10 +180,10 @@ type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
|
||||
Never use `console.log/warn/error` directly — Biome's `noConsole` rule enforces this.
|
||||
|
||||
All logging goes through the structured logger from `@uncaged/workflow`:
|
||||
All logging goes through the structured logger from `@uncaged/workflow-util`:
|
||||
|
||||
```typescript
|
||||
import { createLogger } from "@uncaged/workflow";
|
||||
import { createLogger } from "@uncaged/workflow-util";
|
||||
|
||||
const log = createLogger();
|
||||
|
||||
@@ -197,9 +240,8 @@ Test files (`__tests__/**`) are exempt.
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
bun run check # biome check (lint + format)
|
||||
bun run check # tsc --build + biome check
|
||||
bun run format # biome format --write
|
||||
bun run build # full build
|
||||
bun test # run tests
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32).
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Workflow** | A single-file ESM module exporting `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash. |
|
||||
| **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. |
|
||||
| **Thread** | A single execution of a workflow, identified by a ULID. CAS-backed chain plus `threads.json` / `history/*.jsonl`; `.info.jsonl` for debug logs. |
|
||||
| **Role** | A named actor within a workflow. Each role produces output with typed `meta`. Roles live inside template packages (`src/roles/`). |
|
||||
| **Registry** | `workflow.yaml` — maps workflow names to current/historical bundle hashes. |
|
||||
| **CAS** | Content-Addressed Storage — bundles are immutable and addressed by hash. |
|
||||
|
||||
## Monorepo Packages
|
||||
|
||||
```
|
||||
packages/
|
||||
workflow/ # @uncaged/workflow — core lib (types, engine, hash, ULID, registry)
|
||||
cli-workflow/ # @uncaged/cli-workflow — CLI (`uncaged-workflow` command)
|
||||
workflow-template-develop/ # @uncaged/workflow-template-develop — develop workflow template (includes roles)
|
||||
workflow-template-solve-issue/ # @uncaged/workflow-template-solve-issue — solve-issue workflow template (includes roles)
|
||||
workflow-agent-hermes/ # @uncaged/workflow-agent-hermes — Hermes agent adapter
|
||||
workflow-agent-cursor/ # @uncaged/workflow-agent-cursor — Cursor agent adapter
|
||||
workflow-agent-llm/ # @uncaged/workflow-agent-llm — LLM agent adapter
|
||||
workflow-util-agent/ # @uncaged/workflow-util-agent — agent utilities (buildAgentPrompt, spawnCli)
|
||||
```
|
||||
|
||||
Managed with **bun workspace** using the `workspace:*` protocol.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Build all packages
|
||||
bun run build
|
||||
|
||||
# Register a workflow bundle
|
||||
uncaged-workflow workflow add solve-issue dist/packages/workflow-template-solve-issue/solve-issue.esm.js
|
||||
|
||||
# Run a workflow
|
||||
uncaged-workflow run solve-issue --prompt "Fix bug #42"
|
||||
```
|
||||
|
||||
## CLI Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow # Print full command usage (exits with status 1)
|
||||
uncaged-workflow workflow list # List registered workflows
|
||||
uncaged-workflow run <name> # Start a workflow thread
|
||||
uncaged-workflow thread list # List all threads
|
||||
uncaged-workflow thread show <id> # Inspect a thread
|
||||
uncaged-workflow skill # Agent-consumable reference docs
|
||||
```
|
||||
|
||||
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
bun run check # Biome lint + format check
|
||||
bun run format # Auto-format with Biome
|
||||
bun test # Run tests
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for the full design — three-phase engine loop, bundle contract, storage layout, and design decisions.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
"files": {
|
||||
"includes": ["**", "!**/dist", "!**/node_modules"]
|
||||
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
|
||||
+151
-142
@@ -1,6 +1,6 @@
|
||||
# @uncaged/workflow — Architecture
|
||||
# Uncaged workflow — Architecture
|
||||
|
||||
**Last updated:** 2026-05-06 by 小橘 🍊(NEKO Team)
|
||||
**Last updated:** 2026-05-09
|
||||
|
||||
---
|
||||
|
||||
@@ -8,75 +8,106 @@
|
||||
|
||||
A workflow engine that executes single-file ESM bundles. Each workflow is a self-contained `.esm.js` file identified by its XXH64 hash (Crockford Base32). No daemon — processes start on demand and exit when done.
|
||||
|
||||
## Package Structure
|
||||
The implementation lives in **15** Bun workspace packages under `packages/`, using the `workspace:*` protocol.
|
||||
|
||||
| Package | npm Name | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `workflow` | `@uncaged/workflow` | Core: types, engine, ExtractFn, hash/ULID/registry |
|
||||
| `cli-workflow` | `@uncaged/cli-workflow` | CLI: `uncaged-workflow` command |
|
||||
| `workflow-agent-cursor` | `@uncaged/workflow-agent-cursor` | Cursor CLI agent (extracts workspace from ctx) |
|
||||
| `workflow-agent-hermes` | `@uncaged/workflow-agent-hermes` | Hermes CLI agent |
|
||||
| `workflow-agent-llm` | `@uncaged/workflow-agent-llm` | OpenAI-compatible LLM agent |
|
||||
| `workflow-role-planner` | `@uncaged/workflow-role-planner` | Pure data: phased planning prompt + schema |
|
||||
| `workflow-role-coder` | `@uncaged/workflow-role-coder` | Pure data: coding prompt + schema |
|
||||
| `workflow-role-reviewer` | `@uncaged/workflow-role-reviewer` | Pure data: code review prompt + schema |
|
||||
| `workflow-role-committer` | `@uncaged/workflow-role-committer` | Pure data: git commit prompt + schema |
|
||||
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Composes roles + moderator into a complete workflow |
|
||||
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
|
||||
## Package map
|
||||
|
||||
Monorepo with **bun workspace**, `workspace:*` protocol.
|
||||
Grouped by responsibility (npm name → folder).
|
||||
|
||||
## Core Types
|
||||
| Layer | Package | One-line role |
|
||||
|-------|---------|----------------|
|
||||
| Contract | `@uncaged/workflow-protocol` → `workflow-protocol` | Shared TypeScript types and `Result` helpers; peer `zod` only — no other workspace deps. |
|
||||
| Author API | `@uncaged/workflow-runtime` → `workflow-runtime` | `createWorkflow` and re-exports of protocol workflow types for bundle authors. |
|
||||
| Shared infra | `@uncaged/workflow-util` → `workflow-util` | Base32/ULID, logger, storage root paths, global CAS dir, ref-field helpers. |
|
||||
| LLM plumbing | `@uncaged/workflow-reactor` → `workflow-reactor` | `createLlmFn`, `createThreadReactor`, and related tool-call types for threaded LLM invocation. |
|
||||
| CAS | `@uncaged/workflow-cas` → `workflow-cas` | `CasStore` implementation, XXH64 hashing, Merkle helpers over CAS payloads. |
|
||||
| Registry / bundles | `@uncaged/workflow-register` → `workflow-register` | Bundle validation & dynamic export extraction, `workflow.yaml` registry I/O, provider/model resolution. |
|
||||
| Engine | `@uncaged/workflow-execute` → `workflow-execute` | Thread execution, worker entry path, fork/GC, extract pipeline, `workflowAsAgent`. |
|
||||
| CLI | `@uncaged/cli-workflow` → `cli-workflow` | `uncaged-workflow` binary (depends on engine, registry, CAS, protocol, util, runtime). |
|
||||
| Agent adapters | `@uncaged/workflow-agent-cursor` → `workflow-agent-cursor` | `AgentFn` via `cursor-agent` CLI + workspace extraction. |
|
||||
| | `@uncaged/workflow-agent-hermes` → `workflow-agent-hermes` | `AgentFn` via `hermes chat` CLI. |
|
||||
| | `@uncaged/workflow-agent-llm` → `workflow-agent-llm` | `AgentFn` via OpenAI-compatible HTTP (`LlmProvider` from runtime). |
|
||||
| Agent shared | `@uncaged/workflow-util-agent` → `workflow-util-agent` | `buildAgentPrompt`, `spawnCli` for CLI-backed agents. |
|
||||
| Templates | `@uncaged/workflow-template-develop` → `workflow-template-develop` | Develop workflow definition, roles, descriptor builder. |
|
||||
| | `@uncaged/workflow-template-solve-issue` → `workflow-template-solve-issue` | Solve-issue workflow definition, roles, descriptor builder. |
|
||||
| Dashboard | `@uncaged/workflow-dashboard` → `workflow-dashboard` | Private Vite + React app (`src/main.tsx`); only `react` / `react-dom` dependencies — no workspace packages. |
|
||||
|
||||
```typescript
|
||||
// --- Sentinel values ---
|
||||
const START = "__start__";
|
||||
const END = "__end__";
|
||||
## Dependency graph (workspace packages)
|
||||
|
||||
// --- RoleMeta: maps role names → their meta types ---
|
||||
type RoleMeta = Record<string, Record<string, unknown>>;
|
||||
Bottom-up layering for the execution stack:
|
||||
|
||||
// --- Role Definition: pure data, no execution logic ---
|
||||
type RoleDefinition<Meta> = {
|
||||
description: string; // human-readable
|
||||
systemPrompt: string; // given to agent
|
||||
extractPrompt: string; // given to extractor
|
||||
schema: z.ZodType<Meta>; // meta shape (Zod v4)
|
||||
};
|
||||
|
||||
// --- Workflow Definition: pure data, no agent binding ---
|
||||
type WorkflowDefinition<M extends RoleMeta> = {
|
||||
description: string;
|
||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||
moderator: Moderator<M>;
|
||||
};
|
||||
|
||||
// --- Agent: raw string output, reads role info from context ---
|
||||
type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||
|
||||
// --- Agent Binding: runtime assignment ---
|
||||
type AgentBinding = {
|
||||
agent: AgentFn;
|
||||
overrides?: Partial<Record<string, AgentFn>>;
|
||||
};
|
||||
|
||||
// --- Extract: structured data from context ---
|
||||
type ExtractFn = <T>(schema: z.ZodType<T>, prompt: string, ctx: ExtractContext) => Promise<T>;
|
||||
|
||||
// --- Moderator: pure routing function ---
|
||||
type Moderator<M extends RoleMeta> = (ctx: ModeratorContext<M>) => (keyof M & string) | typeof END;
|
||||
|
||||
// --- Composition ---
|
||||
// createWorkflow(def, binding, extract) => WorkflowFn
|
||||
```mermaid
|
||||
flowchart BT
|
||||
subgraph L0["Layer 0 — contract"]
|
||||
protocol["@uncaged/workflow-protocol"]
|
||||
end
|
||||
subgraph L1["Layer 1 — on protocol"]
|
||||
runtime["@uncaged/workflow-runtime"]
|
||||
util["@uncaged/workflow-util"]
|
||||
reactor["@uncaged/workflow-reactor"]
|
||||
end
|
||||
subgraph L2["Layer 2 — protocol + util"]
|
||||
cas["@uncaged/workflow-cas"]
|
||||
register["@uncaged/workflow-register"]
|
||||
end
|
||||
subgraph L3["Layer 3 — engine"]
|
||||
execute["@uncaged/workflow-execute"]
|
||||
end
|
||||
subgraph L4["Layer 4 — CLI"]
|
||||
cli["@uncaged/cli-workflow"]
|
||||
end
|
||||
runtime --> protocol
|
||||
util --> protocol
|
||||
reactor --> protocol
|
||||
cas --> protocol
|
||||
cas --> util
|
||||
register --> protocol
|
||||
register --> util
|
||||
execute --> protocol
|
||||
execute --> runtime
|
||||
execute --> util
|
||||
execute --> cas
|
||||
execute --> reactor
|
||||
execute --> register
|
||||
cli --> protocol
|
||||
cli --> util
|
||||
cli --> cas
|
||||
cli --> execute
|
||||
cli --> register
|
||||
cli --> runtime
|
||||
```
|
||||
|
||||
## Three-Phase Engine Loop
|
||||
**Adjacent consumers** (not in the main CLI stack):
|
||||
|
||||
Each role execution has three distinct phases with progressive context:
|
||||
- `@uncaged/workflow-util-agent` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-llm` → `@uncaged/workflow-runtime`
|
||||
- `@uncaged/workflow-agent-cursor` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`, `zod`
|
||||
- `@uncaged/workflow-agent-hermes` → `@uncaged/workflow-runtime`, `@uncaged/workflow-util-agent`
|
||||
- `@uncaged/workflow-template-develop` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod`
|
||||
- `@uncaged/workflow-template-solve-issue` → `@uncaged/workflow-register`, `@uncaged/workflow-runtime`, `zod` (dev-only workspace deps: `@uncaged/workflow-cas`, `@uncaged/workflow-execute` for tests/tooling per `package.json`)
|
||||
|
||||
## Package roles (detail)
|
||||
|
||||
- **`workflow-protocol`** — Pure types (`WorkflowFn`, contexts, `CasStore` interface, descriptor shapes), `START` / `END`, `ok` / `err`. Depends only on peer `zod` for schema-related types in signatures.
|
||||
- **`workflow-runtime`** — Workflow author surface: `createWorkflow` from `src/create-workflow.js`, re-exports protocol types/constants used when authoring bundles.
|
||||
- **`workflow-util`** — Cross-cutting utilities: Crockford Base32, ULID, `createLogger`, `getDefaultWorkflowStorageRoot`, `getGlobalCasDir`, ref normalization; re-exports `ok`/`err` from protocol.
|
||||
- **`workflow-cas`** — Filesystem CAS (`createCasStore`), `hashString` / `hashWorkflowBundleBytes`, Merkle node serialization and helpers (`merkle.js`).
|
||||
- **`workflow-register`** — Bundle pipeline (`validateWorkflowBundle`, `extractBundleExports`, descriptor builders), registry YAML read/write, `resolveModel` / `splitProviderModelRef`.
|
||||
- **`workflow-execute`** — `executeThread`, supervisor/worker wiring (`engine/`), fork/GC/pause gate, `createExtract` + LLM extract helpers (`extract/`), `workflowAsAgent`. Imports `@uncaged/workflow-reactor` for LLM-backed extract/supervisor paths (`extract-fn.ts`, `supervisor.ts`).
|
||||
- **`workflow-reactor`** — `createLlmFn`, `createThreadReactor`, and thread tool-invocation types — consumed by `workflow-execute`.
|
||||
- **`cli-workflow`** — CLI commands and HTTP/dashboard-related wiring (`hono`, `yaml`); composes register + execute + CAS + util.
|
||||
- **`workflow-agent-*`** — Replaceable `AgentFn` implementations (Cursor / Hermes CLIs, or HTTP LLM).
|
||||
- **`workflow-util-agent`** — Shared prompt assembly and subprocess spawning for CLI agents.
|
||||
- **`workflow-template-*`** — Concrete `WorkflowDefinition` graphs + Zod role schemas + descriptor builders for publishing bundles.
|
||||
- **`workflow-dashboard`** — Standalone React UI; no published library entry matching `src/index.ts`.
|
||||
|
||||
## Three-phase engine loop
|
||||
|
||||
Each role round is implemented in `packages/workflow-runtime/src/create-workflow.ts` (`advanceOneRound`): moderator → agent → extractor, with progressive context types from `@uncaged/workflow-protocol`.
|
||||
|
||||
```
|
||||
┌─→ Phase 1: MODERATOR
|
||||
│ Context: ModeratorContext { threadId, start, steps }
|
||||
│ Context: ModeratorContext { threadId, depth, start, steps }
|
||||
│ Action: moderator(ctx) → role name | END
|
||||
│
|
||||
│ Phase 2: AGENT
|
||||
@@ -85,98 +116,92 @@ Each role execution has three distinct phases with progressive context:
|
||||
│
|
||||
│ Phase 3: EXTRACTOR
|
||||
│ Context: ExtractContext = AgentCtx + { agentContent }
|
||||
│ Action: extract(schema, extractPrompt, ctx) → typed meta
|
||||
│ Action: runtime.extract(schema, extractPrompt, ctx) → typed meta
|
||||
│
|
||||
│ Merge: RoleStep { role, content, meta, timestamp }
|
||||
│ Merge: RoleStep { role, contentHash, meta, refs, timestamp }
|
||||
│ Append to steps
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Context Types (progressive)
|
||||
### Context types (progressive)
|
||||
|
||||
Defined in `packages/workflow-protocol/src/types.ts`:
|
||||
|
||||
```typescript
|
||||
// Phase 1: Moderator sees accumulated state only
|
||||
type ModeratorContext<M> = {
|
||||
threadId: string;
|
||||
start: StartStep;
|
||||
steps: RoleStep<M>[];
|
||||
};
|
||||
|
||||
// Phase 2: Agent knows its identity
|
||||
type ModeratorContext<M> = ThreadContext<M>;
|
||||
type AgentContext<M> = ModeratorContext<M> & {
|
||||
currentRole: { name: string; systemPrompt: string };
|
||||
};
|
||||
|
||||
// Phase 3: Extractor has agent output
|
||||
type ExtractContext<M> = AgentContext<M> & {
|
||||
agentContent: string;
|
||||
};
|
||||
|
||||
// ThreadContext is an alias for AgentContext (backward compat)
|
||||
type ThreadContext<M> = AgentContext<M>;
|
||||
type ExtractContext<M> = AgentContext<M> & { agentContent: string };
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
### Key properties
|
||||
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation
|
||||
- **Agent gets context, not instructions** — reads `ctx.currentRole.systemPrompt`
|
||||
- **Extractor is a general tool** — not limited to post-agent extraction; agents can use it too (e.g. Cursor agent extracts workspace path before execution)
|
||||
- **extractPrompt is a call parameter**, not context state — different callers use different prompts
|
||||
- **Moderator is synchronous and pure** — no I/O, no state mutation inside `createWorkflow`’s moderator call path.
|
||||
- **Agent receives `AgentContext`** — reads `ctx.currentRole.systemPrompt`; raw output becomes `agentContent` for extract.
|
||||
- **Extractor is `WorkflowRuntime.extract`** — supplied by the engine from registry-resolved LLM config (`workflow-execute`); stores agent body in CAS and yields `contentHash` + `refs` on each step (`create-workflow.ts`).
|
||||
- **`extractPrompt` is a call parameter** on `RoleDefinition`, not implicit context state.
|
||||
|
||||
## Agent Information Sources
|
||||
## Agent information sources
|
||||
|
||||
An agent has exactly three information sources:
|
||||
|
||||
1. **Prior knowledge** — LLM training, agent memory, agent skills
|
||||
2. **Thread context** — `AgentContext` (start, steps, currentRole)
|
||||
2. **Thread context** — `AgentContext` (`start`, `steps`, `currentRole`)
|
||||
3. **Derived information** — from 1 & 2 (e.g. tool calls, shell commands)
|
||||
|
||||
No hidden environment parameters. If an agent needs something (like a workspace path), it extracts it from context using `ExtractFn`.
|
||||
No hidden environment parameters. If an agent needs something (like a workspace path), it obtains it via `ExtractFn` (e.g. Cursor agent).
|
||||
|
||||
## Bundle Contract
|
||||
## Bundle contract
|
||||
|
||||
A workflow bundle is a single `.esm.js` file with two named exports:
|
||||
A workflow bundle is a single `.esm.js` file with two named exports (see `WorkflowFn` / `WorkflowDescriptor` in `packages/workflow-protocol/src/types.ts`):
|
||||
|
||||
```typescript
|
||||
// Named exports (no default export)
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowFn;
|
||||
|
||||
type WorkflowFn = (
|
||||
input: { prompt: string; steps: RoleOutput[] },
|
||||
options: { threadId: string; maxRounds: number },
|
||||
) => AsyncGenerator<RoleOutput, WorkflowResult>;
|
||||
thread: ThreadContext,
|
||||
runtime: WorkflowRuntime,
|
||||
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
|
||||
```
|
||||
|
||||
`RoleOutput` carries `contentHash`, `meta`, and `refs` (agent text lives in CAS, addressed by hash).
|
||||
|
||||
### Constraints
|
||||
|
||||
- Single `.esm.js` file
|
||||
- No dynamic `import()`
|
||||
- All static imports must be Node built-in modules only
|
||||
- XXH64 hash (Crockford Base32) = globally unique version ID
|
||||
- No dynamic `import()` in bundles (loader exempt in engine)
|
||||
- Portable bundle static imports are constrained by validation in `@uncaged/workflow-register` (`validateWorkflowBundle`)
|
||||
- XXH64 hash (Crockford Base32) = version ID
|
||||
|
||||
### Why AsyncGenerator?
|
||||
|
||||
- Each `yield` → engine writes to `.data.jsonl`, checks abort/pause
|
||||
- `return` → engine marks thread complete
|
||||
- Fork = pass historical steps as `input.steps` to a new generator
|
||||
- Zero injection — bundle doesn't import from the engine
|
||||
- Each `yield` lets `workflow-execute` persist state, CAS rows, and enforce pause/abort
|
||||
- `return` supplies `WorkflowCompletion`
|
||||
- Fork replays historical steps into a new thread context
|
||||
- Bundle does not import the engine — only protocol/runtime types at build time
|
||||
|
||||
## Storage Layout
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/
|
||||
├── cas/ # Global content-addressed blobs (see getGlobalCasDir)
|
||||
├── bundles/
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ └── C9NMV6V2TQT81.yaml # Role descriptor
|
||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64
|
||||
│ ├── C9NMV6V2TQT81.yaml # Role descriptor sidecar (when present)
|
||||
│ └── C9NMV6V2TQT81/ # Per-hash bundle dir (alongside or instead of loose files)
|
||||
│ ├── threads.json # Active threads: threadId → { head, start, updatedAt }
|
||||
│ └── history/
|
||||
│ └── 2026-05-09.jsonl # Completed threads (one JSON object per line)
|
||||
├── logs/ # One folder per bundle hash
|
||||
│ └── C9NMV6V2TQT81/
|
||||
│ ├── 01KQXKW…YG.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
|
||||
```
|
||||
|
||||
### ID Encoding: Crockford Base32
|
||||
### ID encoding: Crockford Base32
|
||||
|
||||
- Case-insensitive, filesystem-safe, no ambiguous chars (0/O, 1/I/L)
|
||||
- Bundle hash: XXH64 → 13-char
|
||||
@@ -184,45 +209,31 @@ type WorkflowFn = (
|
||||
|
||||
### Registry (`workflow.yaml`)
|
||||
|
||||
```yaml
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: "C9NMV6V2TQT81"
|
||||
timestamp: 1714963200000
|
||||
history:
|
||||
- hash: "A7BKR3M1NPQ40"
|
||||
timestamp: 1714876800000
|
||||
```
|
||||
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, Line 2+: role outputs
|
||||
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
|
||||
{ "role": "planner", "content": "...", "meta": { "phases": [...] }, "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
|
||||
**`.info.jsonl`** — Structured debug log via `@uncaged/workflow-util` `createLogger`:
|
||||
|
||||
```jsonc
|
||||
{ "tag": "4KNMR2PX", "content": "Loading bundle...", "timestamp": ... }
|
||||
```
|
||||
|
||||
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → instant code location.
|
||||
Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNMR2PX"` → code location.
|
||||
|
||||
## Execution Model
|
||||
## Execution model
|
||||
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process
|
||||
- Same bundle's threads share one process (memory efficiency)
|
||||
- Process exits when all threads complete
|
||||
- Thread termination via IPC within the process
|
||||
- **No daemon.** `uncaged-workflow run <name>` starts a worker process (`workflow-execute` worker entry via `getWorkerHostScriptPath`)
|
||||
- Threads share bundle-scoped workers as implemented in CLI/engine
|
||||
- Pause/resume/abort via engine IPC and pause gate (`createThreadPauseGate`)
|
||||
|
||||
## CLI Commands
|
||||
## CLI commands
|
||||
|
||||
| Priority | Command | Description |
|
||||
|----------|---------|-------------|
|
||||
@@ -242,18 +253,16 @@ Tags are 8-char Crockford Base32 (40-bit random), one per call site. `grep "4KNM
|
||||
| P2 | `resume <thread-id>` | Resume a paused thread |
|
||||
| P3 | `fork <thread-id> [--from-role <role>]` | Fork from historical state |
|
||||
|
||||
All commands implemented and tested. ✅
|
||||
|
||||
## Design Decisions
|
||||
## Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| **Role = pure data** | Decouples definition from execution; same role with different agents |
|
||||
| **Agent bound at runtime** | WorkflowDefinition is reusable; agent choice is deployment concern |
|
||||
| **Three-phase context** | Each phase sees only what it needs; clean separation |
|
||||
| **ExtractFn as general tool** | Agents use it for pre-execution extraction; engine uses it for meta |
|
||||
| **Single-file ESM** | Hash = version, no dependency hell, self-contained |
|
||||
| **No daemon** | OS handles process lifecycle; unnecessary complexity |
|
||||
| **Agent bound at runtime** | `WorkflowDefinition` is reusable; agent choice is deployment concern |
|
||||
| **Three-phase context** | Each phase sees only what it needs; types live in `workflow-protocol` |
|
||||
| **`WorkflowRuntime.extract` + CAS `contentHash`** | Large agent bodies deduplicated globally; Merkle roots summarize threads |
|
||||
| **`workflow-reactor` split** | LLM tool-calling loop isolated from filesystem/registry concerns |
|
||||
| **Single-file ESM** | Hash = version, self-contained bundle |
|
||||
| **No daemon** | OS handles process lifecycle |
|
||||
| **Crockford Base32** | Filesystem-safe, readable, compact |
|
||||
| **No concurrency in registry** | Different workflows have different constraints; belongs at workflow/role level |
|
||||
| **No dryRun** | Tests use mock agents + mock fetch; simpler architecture |
|
||||
| **15-package split** | Clear boundaries: protocol ↔ runtime author API ↔ util/CAS/register ↔ execute ↔ CLI ↔ agents/templates/UI |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Workflow-as-Agent Implementation Plan
|
||||
|
||||
> ⚠️ This plan references the pre-split package structure. File paths have changed.
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Enable workflows to invoke other workflows as agents, backed by global CAS and refs tracking.
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# RFC: CAS-Based Thread Storage
|
||||
|
||||
> Status: Draft
|
||||
> Author: 小橘 🍊(NEKO Team)
|
||||
> Date: 2026-05-09
|
||||
|
||||
## Summary
|
||||
|
||||
Replace `.data.jsonl` with a fully CAS-based thread state chain. Threads become linked lists of immutable CAS nodes, indexed by a per-bundle `threads.json`.
|
||||
|
||||
## Motivation
|
||||
|
||||
`.data.jsonl` is a flat append-only file with three different row formats (start, role step, end). This makes forking expensive (copy file), deduplication impossible (forked threads repeat shared history), and GC complex (must parse every row to find CAS refs).
|
||||
|
||||
Threads are inherently immutable append-only sequences — a natural fit for CAS hash chains, similar to git's commit DAG.
|
||||
|
||||
## Design
|
||||
|
||||
### Node Types
|
||||
|
||||
Two CAS node types, using the existing `{ type, payload, refs }` CAS blob structure:
|
||||
|
||||
#### StartNode
|
||||
|
||||
Contains workflow-level parameters. **No threadId** (because the same StartNode can be shared across forks). Prompt is stored as a CAS blob and referenced via `refs[0]`.
|
||||
|
||||
```
|
||||
CAS blob:
|
||||
{
|
||||
type: "start",
|
||||
payload: {
|
||||
name: "solve-issue",
|
||||
hash: "BUNDLE_HASH",
|
||||
maxRounds: 10,
|
||||
depth: 0
|
||||
},
|
||||
refs: [
|
||||
<prompt_hash> // refs[0]: initial task prompt (CAS blob)
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- No `role`, `content`, `meta` — this is not a step, it's workflow metadata
|
||||
- Prompt is **not** inline — it lives in CAS and is referenced by hash
|
||||
|
||||
#### StateNode
|
||||
|
||||
One per role step (including `__end__`).
|
||||
|
||||
```
|
||||
CAS blob:
|
||||
{
|
||||
type: "state",
|
||||
payload: {
|
||||
role: "coder",
|
||||
meta: { ... },
|
||||
start: "<start_hash>",
|
||||
content: "<content_merkle_hash>",
|
||||
ancestors: ["<parent_hash>", "<grandparent_hash>", ...],
|
||||
compact: null,
|
||||
timestamp: 1234567890
|
||||
},
|
||||
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
|
||||
}
|
||||
```
|
||||
|
||||
**Payload is the source of truth.** Application code reads named fields from payload. `refs[]` is a **GC index** — automatically derived from payload by collecting all CAS hashes. GC only scans `refs[]` without understanding payload structure.
|
||||
|
||||
**Payload fields:**
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `role` | `string` | Role name, or `"__end__"` for completion |
|
||||
| `meta` | `object` | Structured metadata extracted from agent output |
|
||||
| `start` | `string` | StartNode hash |
|
||||
| `content` | `string` | Content Merkle node hash (carries role artifact refs) |
|
||||
| `ancestors` | `string[]` | `[parent, grandparent, ...]` — up to 11 entries (1 parent + 10 skip-list). Empty for first step after start. `ancestors[0]` is the direct parent. |
|
||||
| `compact` | `string \| null` | CAS hash of a compacted summary of all nodes before this one. When present, LLM context assembly can use this instead of walking the full chain. |
|
||||
| `timestamp` | `number` | Unix timestamp in ms |
|
||||
|
||||
### Content Merkle Node
|
||||
|
||||
The content at `refs[2]` of each StateNode is itself a CAS Merkle node. This is where **role artifact references** live:
|
||||
|
||||
```
|
||||
CAS blob:
|
||||
{
|
||||
type: "content",
|
||||
payload: "<role output text>",
|
||||
refs: [
|
||||
<artifact_hash_1>, // e.g. a commit, a file, a sub-result
|
||||
<artifact_hash_2>,
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The Extractor is responsible for producing both `meta` and `refs` from raw agent output:
|
||||
|
||||
```
|
||||
Agent raw output
|
||||
↓
|
||||
Extractor → { meta, contentPayload, refs[] }
|
||||
↓
|
||||
CAS put content Merkle: { type: "content", payload: contentPayload, refs }
|
||||
↓ contentHash
|
||||
StateNode: { ..., refs: [start, parent, contentHash, ...ancestors] }
|
||||
```
|
||||
|
||||
This keeps StateNode refs fixed and simple. All role-specific artifact references are encapsulated in the content Merkle node. GC follows: `thread head → StateNode.refs → content Merkle.refs → artifacts`, full chain recursive.
|
||||
|
||||
### End Node
|
||||
|
||||
An end is just a StateNode with `role: "__end__"`:
|
||||
|
||||
```
|
||||
{
|
||||
type: "state",
|
||||
payload: {
|
||||
role: "__end__",
|
||||
meta: { returnCode: 0, summary: "completed successfully" },
|
||||
start: "<start_hash>",
|
||||
content: "<content_hash>",
|
||||
ancestors: ["<parent_hash>", ...],
|
||||
compact: null,
|
||||
timestamp: 1234567891
|
||||
},
|
||||
refs: [<start_hash>, <content_hash>, <parent_hash>, ...]
|
||||
}
|
||||
```
|
||||
|
||||
### Thread Index: `threads.json`
|
||||
|
||||
Per-bundle directory, one `threads.json` file. **Only active (in-progress) threads** live here:
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/bundles/<hash>/threads.json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"01JTHREAD1AAAAAAAAAAAAAAA": {
|
||||
"head": "<latest_state_node_hash>",
|
||||
"start": "<start_node_hash>",
|
||||
"updatedAt": 1234567891
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When a thread completes (`__end__`), it is **removed from `threads.json`** and appended to a date-partitioned history file:
|
||||
|
||||
```
|
||||
~/.uncaged/workflow/bundles/<hash>/history/{YYYY-MM-DD}.jsonl
|
||||
```
|
||||
|
||||
Each line:
|
||||
|
||||
```json
|
||||
{"threadId":"01JTHREAD1AAAAAAAAAAAAAAA","head":"<end_node_hash>","start":"<start_node_hash>","completedAt":1234567891}
|
||||
```
|
||||
|
||||
Benefits:
|
||||
- `threads.json` stays small — only in-flight threads
|
||||
- Dashboard watches `threads.json` for real-time updates; completed threads don't trigger watches
|
||||
- History is queryable by date but not actively monitored
|
||||
- GC roots = all heads from `threads.json` + all heads from `history/*.jsonl`
|
||||
|
||||
### Ancestor Skip-List
|
||||
|
||||
Each StateNode carries up to 11 entries in `payload.ancestors` (1 parent + 10 skip-list, newest first):
|
||||
|
||||
```
|
||||
Node 15: ancestors = [node14, node13, node12, node11, node10, node9, node8, node7, node6, node5, node4]
|
||||
^parent ^--- skip-list (10 most recent) ---^
|
||||
```
|
||||
|
||||
This enables:
|
||||
- **Paginated fetch**: jump to any recent ancestor without walking the full chain
|
||||
- **Partial replay**: fetch last N steps without loading the entire history
|
||||
- The list is capped at 10 to keep node size bounded
|
||||
|
||||
### Fork
|
||||
|
||||
Forking a thread at step N:
|
||||
|
||||
1. Create new threadId
|
||||
2. Create a new StateNode whose `parent` (refs[1]) points to the fork point's StateNode
|
||||
3. Register the new threadId in `threads.json` with its own head
|
||||
4. **Zero data duplication** — the forked thread shares all ancestor nodes via CAS
|
||||
|
||||
### Compact
|
||||
|
||||
When a StateNode has `payload.compact` set:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "state",
|
||||
"payload": {
|
||||
"role": "coder",
|
||||
"meta": { ... },
|
||||
"compact": "<cas_hash_of_summary>",
|
||||
"timestamp": 1234
|
||||
},
|
||||
"refs": [...]
|
||||
}
|
||||
```
|
||||
|
||||
This means: "everything before this node has been summarized into the blob at `compact`". When building LLM context:
|
||||
|
||||
1. Walk back from head
|
||||
2. If a node has `compact`, stop walking — use the compact summary + all nodes after it
|
||||
3. If no compact found, use full chain
|
||||
|
||||
This enables long-running threads without unbounded context growth.
|
||||
|
||||
### GC
|
||||
|
||||
Simple mark-and-sweep:
|
||||
|
||||
1. **Roots**: all `head` and `start` hashes from `threads.json` + all `history/*.jsonl` files
|
||||
2. **Mark**: from each root, recursively mark all reachable hashes via `refs[]` (including content Merkle → artifact refs)
|
||||
3. **Sweep**: delete unmarked CAS blobs
|
||||
|
||||
No per-row format parsing needed. GC only needs to understand `refs[]`.
|
||||
|
||||
### refs[] Derivation
|
||||
|
||||
`refs[]` is auto-derived from payload at write time via a `collectRefs(payload)` function that extracts all CAS hash strings from named fields (`start`, `content`, `ancestors`, `compact`). Application code never reads `refs[]` — it reads named payload fields. This makes `refs[]` a pure GC optimization with zero semantic coupling.
|
||||
|
||||
### Extract Phase
|
||||
|
||||
The Extractor is expanded from the current design. Currently it only extracts `meta` from agent output. In the new design it extracts:
|
||||
|
||||
| Output | Purpose |
|
||||
|--------|---------|
|
||||
| `meta` | Structured metadata (same as before) |
|
||||
| `contentPayload` | The text payload for the content Merkle node |
|
||||
| `refs[]` | CAS hashes of artifacts produced by this role step |
|
||||
|
||||
The `refs[]` become the content Merkle node's refs, enabling GC to trace all role-produced artifacts.
|
||||
|
||||
## What Stays Unchanged
|
||||
|
||||
- `.info.jsonl` — debug logging stays as-is (high-frequency append, not suitable for CAS)
|
||||
- CAS blob storage format (`~/.uncaged/workflow/cas/`)
|
||||
- Bundle registry (`workflow.yaml`)
|
||||
|
||||
## Migration
|
||||
|
||||
Breaking change. Old `.data.jsonl` files become incompatible. No backward compat fallback (per project convention).
|
||||
|
||||
## Changes by Package
|
||||
|
||||
| Package | Changes |
|
||||
|---------|---------|
|
||||
| `workflow-protocol` | Replace `StartStep`, `RoleStep` types with `StartNode`, `StateNode`. Add `ContentMerkleNode` type. Expand `ExtractResult` to include `refs[]`. |
|
||||
| `workflow-cas` | Add `findReachableHashes(roots)` for GC mark phase |
|
||||
| `workflow-execute` | Rewrite engine to write CAS nodes + update `threads.json` instead of appending JSONL. Move completed threads to `history/`. Simplify `gc.ts`. Simplify `fork-thread.ts`. Expand extract phase to produce refs. |
|
||||
| `workflow-runtime` | `ThreadContext` built by walking chain from head. `start.prompt` resolved from CAS via StartNode.refs[0]. |
|
||||
| `cli-workflow` | `thread list/show/rm` read from `threads.json` + `history/`. SSE watches `threads.json`. |
|
||||
| `workflow-dashboard` | Watch `threads.json` instead of `.data.jsonl` |
|
||||
| Templates & Agents | Update extract definitions to produce `refs[]`. Update `ctx.start.content` → CAS resolved. |
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
type Roles = {
|
||||
greeter: { greeting: string };
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
greeting: z.string(),
|
||||
});
|
||||
|
||||
export const descriptor = {
|
||||
description: "A simple hello world workflow",
|
||||
roles: {
|
||||
greeter: {
|
||||
description: "Generates a greeting",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { greeting: { type: "string" } },
|
||||
required: ["greeting"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const greeter: RoleDefinition<Roles["greeter"]> = {
|
||||
description: "Generates a greeting",
|
||||
systemPrompt: "You greet the user briefly.",
|
||||
extractPrompt: "Extract the greeting string produced for the user.",
|
||||
schema: greeterMetaSchema,
|
||||
extractRefs: null,
|
||||
};
|
||||
|
||||
const extract = createExtract({
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
});
|
||||
|
||||
export const run = createWorkflow<Roles>(
|
||||
{
|
||||
roles: { greeter },
|
||||
moderator(ctx) {
|
||||
return ctx.steps.length === 0 ? "greeter" : END;
|
||||
},
|
||||
},
|
||||
{
|
||||
agent: async (ctx) => `Hello, ${ctx.start.content}`,
|
||||
},
|
||||
extract,
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-examples",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -2,11 +2,10 @@
|
||||
"name": "@uncaged/workflow-monorepo",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"examples"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun run --filter '*' build",
|
||||
"build": "bunx tsc --build",
|
||||
"check": "bunx tsc --build && biome check .",
|
||||
"typecheck": "bunx tsc --build",
|
||||
"format": "biome format --write .",
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# @uncaged/cli-workflow
|
||||
|
||||
Command-line interface for the Uncaged workflow engine (`uncaged-workflow`).
|
||||
|
||||
The CLI reads and writes the workflow registry, starts and inspects threads, manages CAS blobs, and prints agent-oriented reference docs via `skill`. It uses the same storage layout as `@uncaged/workflow` (default `~/.uncaged/workflow`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/cli-workflow
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/cli-workflow": "workspace:*"`. Depends on `"@uncaged/workflow": "workspace:*"`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uncaged-workflow workflow list
|
||||
uncaged-workflow run <name> --prompt "Your task"
|
||||
uncaged-workflow thread show <id>
|
||||
uncaged-workflow skill
|
||||
```
|
||||
|
||||
Invoking the CLI with no command (or from this repo: `bun packages/cli-workflow/src/cli.ts`) prints:
|
||||
|
||||
```
|
||||
uncaged-workflow — workflow engine CLI
|
||||
|
||||
Workflow registry:
|
||||
workflow add <name> <file.esm.js> [--types <path>] Register a workflow bundle in the registry
|
||||
workflow list List all registered workflows
|
||||
workflow show <name> Show details of a registered workflow
|
||||
workflow rm <name> Remove a workflow from the registry
|
||||
workflow history <name> Show version history of a workflow
|
||||
workflow rollback <name> [hash] Rollback a workflow to a previous version
|
||||
|
||||
Thread execution:
|
||||
thread run <name> [--prompt <text>] [--max-rounds N] Start a new thread executing a workflow
|
||||
thread list [name] List threads, optionally filtered by workflow name
|
||||
thread show <id> Show thread details and state
|
||||
thread rm <id> Remove a thread
|
||||
thread fork <thread-id> [--from-role <role>] Fork a thread, optionally from a specific role
|
||||
thread ps List running threads
|
||||
thread kill <thread-id> Kill a running thread
|
||||
thread live <thread-id> | --latest [--debug] [--role <name>] Attach to a thread and stream output live
|
||||
thread pause <thread-id> Pause a running thread
|
||||
thread resume <thread-id> Resume a paused thread
|
||||
|
||||
Content-addressable storage:
|
||||
cas get <hash> Retrieve content by hash from CAS
|
||||
cas put <content> Store content in CAS, prints hash
|
||||
cas list List all hashes in CAS
|
||||
cas rm <hash> Remove a CAS entry by hash
|
||||
cas gc Garbage-collect unreferenced CAS entries
|
||||
|
||||
Development:
|
||||
init workspace <name> Initialize a new workflow workspace
|
||||
init template <name> Initialize a new workflow template
|
||||
|
||||
Shortcuts:
|
||||
run <name> [...] → thread run
|
||||
live <id> [...] → thread live
|
||||
|
||||
Reference:
|
||||
skill [topic] Agent-consumable docs (cli, develop, author)
|
||||
|
||||
Use <command> --help for subcommand details.
|
||||
|
||||
Environment variables:
|
||||
WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)
|
||||
UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
This package is bin-only; programmatic use is via `@uncaged/workflow`. Entry: `src/cli.ts` → `runCli` in `src/cli-dispatch.js`.
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ParsedAddArgv } from "../src/cmd-add.js";
|
||||
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
|
||||
|
||||
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
||||
return { name, filePath, typesPath: null };
|
||||
|
||||
@@ -2,20 +2,31 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/cmd-cas.js";
|
||||
import { cmdHistory } from "../src/cmd-history.js";
|
||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
||||
import { cmdRemove } from "../src/cmd-remove.js";
|
||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
||||
import { cmdShow } from "../src/cmd-show.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdAdd,
|
||||
cmdHistory,
|
||||
cmdList,
|
||||
cmdRemove,
|
||||
cmdRollback,
|
||||
cmdShow,
|
||||
formatListLines,
|
||||
} 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";
|
||||
`;
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -41,11 +52,13 @@ describe("cli workflow commands", () => {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}import fs from "node:fs";
|
||||
`${fixtureDescriptor}${wfPutImport}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input) {
|
||||
export const run = async function* (input, options) {
|
||||
fs.existsSync(".");
|
||||
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "noop", contentHash: h, meta: { done: true }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
}
|
||||
`,
|
||||
@@ -112,8 +125,8 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
const bundlePath = join(storageRoot, "solo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`export const run = async function* (input) {
|
||||
yield { role: "x", content: input.prompt, meta: {} };
|
||||
`export const run = async function* () {
|
||||
yield { role: "x", contentHash: "STUBHASH00000000000000001", meta: {}, refs: [] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
}
|
||||
`,
|
||||
@@ -141,8 +154,11 @@ export const run = async function* (input) { return { returnCode: 0, summary: in
|
||||
},
|
||||
},
|
||||
};
|
||||
export const run = async function* (input) {
|
||||
yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, input.prompt);
|
||||
yield { role: "greeter", contentHash: h, meta: { greeting: "hi" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "ok" };
|
||||
};
|
||||
`,
|
||||
@@ -180,8 +196,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -209,8 +227,10 @@ export const run = async function* (input) {
|
||||
const dtsPath = join(bundleDir, "types.d.ts");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -240,8 +260,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -261,13 +283,17 @@ export const run = async function* (input) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -299,13 +325,17 @@ export const run = async function* (input) {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
const v1 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v1", meta: {} };
|
||||
const v1 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v1");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v1" };
|
||||
}
|
||||
`;
|
||||
const v2 = `${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "v2", meta: {} };
|
||||
const v2 = `${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "v2");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "v2" };
|
||||
}
|
||||
`;
|
||||
@@ -347,8 +377,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -358,8 +390,10 @@ export const run = async function* (input) {
|
||||
expect(add1.ok).toBe(true);
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
@@ -373,33 +407,35 @@ export const run = async function* (input) {
|
||||
});
|
||||
|
||||
test("cas put/get/list/rm use global cas dir (thread id not required for storage)", async () => {
|
||||
const put = await cmdCasPut(storageRoot, "nonexistent-thread-id", "phase doc");
|
||||
const raw = "phase doc";
|
||||
const stored = casStoredForm(raw);
|
||||
const put = await cmdCasPut(storageRoot, raw);
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
}
|
||||
const hash = put.value;
|
||||
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
|
||||
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
|
||||
expect(await readFile(blobPath, "utf8")).toBe(stored);
|
||||
|
||||
const got = await cmdCasGet(storageRoot, "other-thread", hash);
|
||||
const got = await cmdCasGet(storageRoot, hash);
|
||||
expect(got.ok).toBe(true);
|
||||
if (!got.ok) {
|
||||
return;
|
||||
}
|
||||
expect(got.value).toBe("phase doc");
|
||||
expect(got.value).toBe(stored);
|
||||
|
||||
const listed = await cmdCasList(storageRoot, "another-thread");
|
||||
const listed = await cmdCasList(storageRoot);
|
||||
expect(listed.ok).toBe(true);
|
||||
if (!listed.ok) {
|
||||
return;
|
||||
}
|
||||
expect(listed.value).toContain(hash);
|
||||
|
||||
const removed = await cmdCasRm(storageRoot, "rm-thread", hash);
|
||||
const removed = await cmdCasRm(storageRoot, hash);
|
||||
expect(removed.ok).toBe(true);
|
||||
|
||||
const missing = await cmdCasGet(storageRoot, "after-rm", hash);
|
||||
const missing = await cmdCasGet(storageRoot, hash);
|
||||
expect(missing.ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -409,8 +445,10 @@ export const run = async function* (input) {
|
||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "x", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "x" };
|
||||
}
|
||||
`,
|
||||
@@ -424,8 +462,10 @@ export const run = async function* (input) {
|
||||
const hash1 = add1.value.hash;
|
||||
await writeFile(
|
||||
bundlePath,
|
||||
`${fixtureDescriptor}export const run = async function* (input) {
|
||||
yield { role: "a", content: "y", meta: {} };
|
||||
`${fixtureDescriptor}${wfPutImport}export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "y");
|
||||
yield { role: "a", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "y" };
|
||||
}
|
||||
`,
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVECMPLT01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963400000}
|
||||
{"role":"planner","contentHash":"FF7YQ5W3S2EV6","meta":{"phase":"plan","flags":[1,2]},"refs":[],"timestamp":1714963201000}
|
||||
{"role":"coder","contentHash":"EN34XX1W4WAFJ","meta":{},"refs":[],"timestamp":1714963202000}
|
||||
{"returnCode":0,"summary":"fixture completed"}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"tag":"DEBUGTAG1","content":"bundle loaded","timestamp":1714963400050}
|
||||
{"tag":"DEBUGTAG2","content":"multi\nline","timestamp":1714963400500}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"name":"demo-live","hash":"C9NMV6V2TQT81","threadId":"01LIVEINFLY01DDDDDDDDDDDDG","parameters":{"prompt":"hello","options":{"maxRounds":5,"depth":0}},"timestamp":1714963200000}
|
||||
{"role":"planner","contentHash":"P6M9FHE1GSBN0","meta":{"x":1},"refs":[],"timestamp":1714963201000}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"name":"demo-live-old","hash":"C9NMV6V2TQT81","threadId":"01LIVEOLDER01DDDDDDDDDDDDG","parameters":{"prompt":"old","options":{"maxRounds":5,"depth":0}},"timestamp":1714963000000}
|
||||
{"returnCode":0,"summary":"older thread"}
|
||||
@@ -1,15 +1,23 @@
|
||||
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 { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdFork } from "../src/cmd-fork.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
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 = `export const descriptor = {
|
||||
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
export const descriptor = {
|
||||
description: "fork-cli",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
@@ -17,46 +25,26 @@ const threeRoleBundleSource = `export const descriptor = {
|
||||
reviewer: { description: "reviewer", schema: {} },
|
||||
},
|
||||
};
|
||||
export const run = async function* (input) {
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
yield { role: "planner", content: "p1", meta: { k: "planner" } };
|
||||
const h = await putContentMerkleNode(cas, "p1");
|
||||
yield { role: "planner", contentHash: h, meta: { k: "planner" }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
yield { role: "coder", content: "c1", meta: { k: "coder" } };
|
||||
const h = await putContentMerkleNode(cas, "c1");
|
||||
yield { role: "coder", contentHash: h, meta: { k: "coder" }, refs: [h] };
|
||||
}
|
||||
if (!has("reviewer")) {
|
||||
yield {
|
||||
role: "reviewer",
|
||||
content: "rev-" + String(input.steps.length),
|
||||
meta: { k: "reviewer" },
|
||||
};
|
||||
const body = "rev-" + String(input.steps.length);
|
||||
const h = await putContentMerkleNode(cas, 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))) {
|
||||
@@ -66,6 +54,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;
|
||||
@@ -74,6 +97,7 @@ describe("cli fork", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -104,10 +128,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, 4);
|
||||
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);
|
||||
@@ -115,24 +141,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, 4);
|
||||
await waitUntilThreadCompletes(storageRoot, newId);
|
||||
|
||||
const text = await readFile(newData, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(4);
|
||||
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 last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).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 () => {
|
||||
@@ -154,10 +174,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, 4);
|
||||
await waitUntilRunningAbsent(join(storageRoot, "logs", hash, `${sourceId}.running`));
|
||||
await waitUntilThreadCompletes(storageRoot, sourceId);
|
||||
|
||||
const forked = await cmdFork(storageRoot, sourceId, null);
|
||||
expect(forked.ok).toBe(true);
|
||||
@@ -165,25 +183,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, 4);
|
||||
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(4);
|
||||
|
||||
const replayCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(replayCoder.role).toBe("coder");
|
||||
expect(replayCoder.content).toBe("c1");
|
||||
|
||||
const last = JSON.parse(lines[lines.length - 1] ?? "{}") as Record<string, unknown>;
|
||||
expect(last.role).toBe("reviewer");
|
||||
expect(last.content).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 () => {
|
||||
@@ -204,10 +214,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, 4);
|
||||
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,36 +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, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdThreadRemove } from "../src/cmd-thread.js";
|
||||
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));
|
||||
|
||||
/** Minimal valid `.data.jsonl` with one role step referencing `activeHash` in `refs`. */
|
||||
function makeDataJsonl(threadId: string, bundleHash: string, activeHash: string): string {
|
||||
return [
|
||||
JSON.stringify({
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
threadId,
|
||||
parameters: { prompt: "hi", options: { maxRounds: 5 } },
|
||||
timestamp: 100,
|
||||
}),
|
||||
JSON.stringify({
|
||||
role: "planner",
|
||||
content: "p",
|
||||
meta: {},
|
||||
refs: [activeHash],
|
||||
timestamp: 101,
|
||||
}),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
describe("gc cli and garbageCollectCas", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
@@ -50,33 +31,43 @@ 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 writeFile(
|
||||
join(logsDir, `${threadId}.data.jsonl`),
|
||||
makeDataJsonl(threadId, bundleHash, activeHash),
|
||||
"utf8",
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const gc = await garbageCollectCas(storageRoot);
|
||||
expect(gc.ok).toBe(true);
|
||||
if (!gc.ok) {
|
||||
return;
|
||||
}
|
||||
expect(gc.value.scannedThreads).toBe(1);
|
||||
expect(gc.value.activeRefs).toBe(1);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -99,39 +90,63 @@ 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,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
await cas.put("drop-me");
|
||||
|
||||
await writeFile(
|
||||
join(logsDir, `${threadId}.data.jsonl`),
|
||||
makeDataJsonl(threadId, bundleHash, activeHash),
|
||||
"utf8",
|
||||
);
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const proc = spawnSync(process.execPath, [cliEntryPath, "gc"], { env, encoding: "utf8" });
|
||||
const proc = spawnSync(process.execPath, [cliEntryPath, "cas", "gc"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(proc.status).toBe(0);
|
||||
expect(String(proc.stdout).trim()).toBe("scanned 1 threads, 1 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 writeFile(
|
||||
join(logsDir, `${threadId}.data.jsonl`),
|
||||
makeDataJsonl(threadId, bundleHash, activeHash),
|
||||
"utf8",
|
||||
const promptHash = await cas.put("prompt-text");
|
||||
const startHash = await putStartNode(
|
||||
cas,
|
||||
{
|
||||
name: "demo",
|
||||
hash: bundleHash,
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
promptHash,
|
||||
);
|
||||
|
||||
await upsertThreadEntry(bundleDir, threadId, {
|
||||
head: startHash,
|
||||
start: startHash,
|
||||
updatedAt: 100,
|
||||
});
|
||||
|
||||
const orphanHash = await cas.put("orphan-after-rm");
|
||||
const orphanPath = join(getGlobalCasDir(storageRoot), `${orphanHash}.txt`);
|
||||
|
||||
@@ -139,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
|
||||
|
||||
const STORAGE_ROOT = "/tmp/help-test-storage";
|
||||
|
||||
describe("runCli usage", () => {
|
||||
test("no args prints usage and returns 1", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, []);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill command", () => {
|
||||
test("skill (no topic) lists topics and returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill cli returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "cli"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill develop returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "develop"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill author returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "author"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("skill unknown returns 1", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["skill", "unknown"]);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("--help flag on groups", () => {
|
||||
test("workflow --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["workflow", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("thread --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["thread", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("cas --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["cas", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
|
||||
test("init --help returns 0", async () => {
|
||||
const code = await runCli(STORAGE_ROOT, ["init", "--help"]);
|
||||
expect(code).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkillTopics", () => {
|
||||
test("returns all topics", () => {
|
||||
const topics = getSkillTopics();
|
||||
const names = topics.map((t) => t.name);
|
||||
expect(names).toContain("cli");
|
||||
expect(names).toContain("develop");
|
||||
expect(names).toContain("author");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillIndex", () => {
|
||||
test("lists all topics", () => {
|
||||
const idx = formatSkillIndex();
|
||||
expect(idx).toContain("# uncaged-workflow skill");
|
||||
expect(idx).not.toContain("# uncaged-workflow help --skill");
|
||||
expect(idx).toContain("cli");
|
||||
expect(idx).toContain("develop");
|
||||
expect(idx).toContain("author");
|
||||
expect(idx).toContain("skill <topic>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCliUsage", () => {
|
||||
test("has tagline, grouped sections, help hint, and env vars", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u.startsWith("uncaged-workflow — workflow engine CLI")).toBe(true);
|
||||
expect(u).toContain("Workflow registry:");
|
||||
expect(u).toContain("Thread execution:");
|
||||
expect(u).toContain("Content-addressable storage:");
|
||||
expect(u).toContain("Development:");
|
||||
expect(u).toContain("Shortcuts:");
|
||||
expect(u).toContain("Reference:");
|
||||
expect(u).toContain("skill [topic]");
|
||||
expect(u).toContain("Agent-consumable docs");
|
||||
expect(u).toContain("Use <command> --help for subcommand details.");
|
||||
expect(u).toContain("Environment variables:");
|
||||
expect(u).toContain("WORKFLOW_STORAGE_ROOT");
|
||||
expect(u).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||
});
|
||||
|
||||
test("lists commands from registry with descriptions", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("workflow add");
|
||||
expect(u).toContain("Register a workflow bundle in the registry");
|
||||
expect(u).toContain("thread run");
|
||||
expect(u).toContain("Start a new thread executing a workflow");
|
||||
expect(u).toContain("cas gc");
|
||||
expect(u).toContain("Garbage-collect unreferenced CAS entries");
|
||||
});
|
||||
});
|
||||
|
||||
const cliSkillDoc = formatSkillTopic("cli");
|
||||
if (cliSkillDoc === null) {
|
||||
throw new Error("BUG: cli skill topic missing");
|
||||
}
|
||||
|
||||
describe("formatSkillTopic('cli')", () => {
|
||||
const doc = cliSkillDoc;
|
||||
|
||||
test("contains title", () => {
|
||||
expect(doc).toContain("# uncaged-workflow CLI Reference");
|
||||
});
|
||||
|
||||
test("contains all command group headers", () => {
|
||||
expect(doc).toContain("### workflow");
|
||||
expect(doc).toContain("### thread");
|
||||
expect(doc).toContain("### cas");
|
||||
expect(doc).toContain("### init");
|
||||
expect(doc).toContain("### Top-level shortcuts");
|
||||
});
|
||||
|
||||
test("contains core concepts", () => {
|
||||
expect(doc).toContain("## Core Concepts");
|
||||
expect(doc).toContain("Workflow");
|
||||
expect(doc).toContain("Bundle");
|
||||
expect(doc).toContain("Thread");
|
||||
expect(doc).toContain("CAS");
|
||||
expect(doc).toContain("Registry");
|
||||
});
|
||||
|
||||
test("mentions all workflow subcommands", () => {
|
||||
for (const sub of ["add", "list", "show", "rm", "history", "rollback"]) {
|
||||
expect(doc).toContain(`workflow ${sub}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("mentions all thread subcommands", () => {
|
||||
for (const sub of [
|
||||
"run",
|
||||
"list",
|
||||
"show",
|
||||
"rm",
|
||||
"fork",
|
||||
"ps",
|
||||
"kill",
|
||||
"live",
|
||||
"pause",
|
||||
"resume",
|
||||
]) {
|
||||
expect(doc).toContain(`thread ${sub}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("mentions all cas subcommands", () => {
|
||||
for (const sub of ["get", "put", "list", "rm", "gc"]) {
|
||||
expect(doc).toContain(`cas ${sub}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("contains exit codes section", () => {
|
||||
expect(doc).toContain("## Exit Codes");
|
||||
});
|
||||
|
||||
test("contains environment variables section", () => {
|
||||
expect(doc).toContain("## Environment Variables");
|
||||
expect(doc).toContain("UNCAGED_WORKFLOW_STORAGE_ROOT");
|
||||
});
|
||||
|
||||
test("contains typical workflow section", () => {
|
||||
expect(doc).toContain("## Typical Workflow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillTopic('develop')", () => {
|
||||
const doc = formatSkillTopic("develop");
|
||||
|
||||
test("returns non-null", () => {
|
||||
expect(doc).not.toBeNull();
|
||||
});
|
||||
|
||||
test("contains thread ID info", () => {
|
||||
expect(doc).toContain("Thread ID");
|
||||
expect(doc).toContain("Crockford Base32");
|
||||
});
|
||||
|
||||
test("contains CAS commands", () => {
|
||||
expect(doc).toContain("cas put");
|
||||
expect(doc).toContain("cas get");
|
||||
});
|
||||
|
||||
test("contains meta output section", () => {
|
||||
expect(doc).toContain("Meta Output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillTopic('author')", () => {
|
||||
const doc = formatSkillTopic("author");
|
||||
|
||||
test("returns non-null", () => {
|
||||
expect(doc).not.toBeNull();
|
||||
});
|
||||
|
||||
test("contains bundle structure", () => {
|
||||
expect(doc).toContain("Bundle Structure");
|
||||
expect(doc).toContain(".esm.js");
|
||||
});
|
||||
|
||||
test("contains descriptor info", () => {
|
||||
expect(doc).toContain("WorkflowDescriptor");
|
||||
});
|
||||
|
||||
test("contains role definition", () => {
|
||||
expect(doc).toContain("Role Definition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSkillTopic unknown", () => {
|
||||
test("returns null for unknown topic", () => {
|
||||
expect(formatSkillTopic("nonexistent")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitTemplate, cmdInitWorkspace } from "../src/commands/init/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init template", () => {
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = join(
|
||||
tmpdir(),
|
||||
`wf-init-template-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
await mkdir(parent, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates templates/<name> with expected files", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
|
||||
const created = await cmdInitTemplate(root, "review-pr");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tdir = join(root, "templates", "review-pr");
|
||||
expect(created.value.templatePath).toBe(tdir);
|
||||
expect(await pathExists(join(tdir, "package.json"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "tsconfig.json"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "roles.ts"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "moderator.ts"))).toBe(true);
|
||||
expect(await pathExists(join(tdir, "src", "index.ts"))).toBe(true);
|
||||
|
||||
const pkg = JSON.parse(await readFile(join(tdir, "package.json"), "utf8")) as {
|
||||
name: string;
|
||||
type: string;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(pkg.type).toBe("module");
|
||||
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(pkg.dependencies.zod).toBeDefined();
|
||||
expect(pkg.name).toContain("review-pr");
|
||||
|
||||
const idx = await readFile(join(tdir, "src", "index.ts"), "utf8");
|
||||
expect(idx).toContain("WorkflowDefinition");
|
||||
|
||||
const roles = await readFile(join(tdir, "src", "roles.ts"), "utf8");
|
||||
expect(roles).not.toContain("interface ");
|
||||
expect(roles).not.toContain("?:");
|
||||
expect(roles).not.toContain("export default");
|
||||
|
||||
const moder = await readFile(join(tdir, "src", "moderator.ts"), "utf8");
|
||||
expect(moder).not.toContain("export default");
|
||||
});
|
||||
|
||||
test("finds workspace walking up from nested cwd", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
const nested = join(root, "a", "b");
|
||||
await mkdir(nested, { recursive: true });
|
||||
|
||||
const created = await cmdInitTemplate(nested, "nested-tpl");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
expect(await pathExists(join(root, "templates", "nested-tpl", "src", "index.ts"))).toBe(true);
|
||||
});
|
||||
|
||||
test("errors when not inside a workflow workspace", async () => {
|
||||
const orphan = join(parent, "nowhere");
|
||||
await mkdir(orphan, { recursive: true });
|
||||
const r = await cmdInitTemplate(orphan, "x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("templates/*");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors when template directory already exists", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
|
||||
const first = await cmdInitTemplate(root, "dup");
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const second = await cmdInitTemplate(root, "dup");
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok) {
|
||||
expect(second.error).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
test("errors on invalid template name", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const bad = await cmdInitTemplate(ws.value.rootPath, "a/b");
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test.serial("runCli init template uses cwd and succeeds in workspace", async () => {
|
||||
const ws = await cmdInitWorkspace(parent, "cli-ws");
|
||||
expect(ws.ok).toBe(true);
|
||||
if (!ws.ok) {
|
||||
return;
|
||||
}
|
||||
const root = ws.value.rootPath;
|
||||
const prev = process.cwd();
|
||||
try {
|
||||
process.chdir(root);
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "template", "from-cli"]);
|
||||
expect(code).toBe(0);
|
||||
expect(await pathExists(join(root, "templates", "from-cli", "package.json"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
|
||||
import { cmdInitWorkspace } from "../src/commands/init/index.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
|
||||
describe("init workspace", () => {
|
||||
let parent: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
parent = join(tmpdir(), `wf-init-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(parent, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(parent, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("creates expected files and directories", async () => {
|
||||
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = created.value.rootPath;
|
||||
expect(await pathExists(join(root, "package.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "biome.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "tsconfig.json"))).toBe(true);
|
||||
expect(await pathExists(join(root, "AGENTS.md"))).toBe(true);
|
||||
expect(await pathExists(join(root, "README.md"))).toBe(true);
|
||||
expect(await pathExists(join(root, "templates"))).toBe(true);
|
||||
expect(await pathExists(join(root, "templates", ".gitkeep"))).toBe(true);
|
||||
expect(await pathExists(join(root, "workflows", "package.json"))).toBe(true);
|
||||
|
||||
const rootPkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as {
|
||||
workspaces: string[];
|
||||
};
|
||||
expect(rootPkg.workspaces).toEqual(["templates/*", "workflows"]);
|
||||
|
||||
const wfPkg = JSON.parse(await readFile(join(root, "workflows", "package.json"), "utf8")) as {
|
||||
type: string;
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
expect(wfPkg.type).toBe("module");
|
||||
expect(wfPkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
|
||||
expect(wfPkg.dependencies.zod).toBeDefined();
|
||||
|
||||
const tsconfig = JSON.parse(await readFile(join(root, "tsconfig.json"), "utf8")) as {
|
||||
compilerOptions: { strict: boolean; module: string; target: string };
|
||||
};
|
||||
expect(tsconfig.compilerOptions.strict).toBe(true);
|
||||
expect(tsconfig.compilerOptions.module).toBe("ESNext");
|
||||
expect(tsconfig.compilerOptions.target).toBe("ESNext");
|
||||
});
|
||||
|
||||
test("AGENTS.md contains coding agent guide sections and terms", async () => {
|
||||
const created = await cmdInitWorkspace(parent, "my-workflows");
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentsPath = join(created.value.rootPath, "AGENTS.md");
|
||||
const body = await readFile(agentsPath, "utf8");
|
||||
|
||||
for (const section of [
|
||||
"项目结构",
|
||||
"核心概念",
|
||||
"开发流程",
|
||||
"编码规范",
|
||||
"Template",
|
||||
"Build",
|
||||
"常见陷阱",
|
||||
]) {
|
||||
expect(body).toContain(section);
|
||||
}
|
||||
|
||||
for (const term of [
|
||||
"RoleDefinition",
|
||||
"WorkflowDefinition",
|
||||
"Moderator",
|
||||
"AgentFn",
|
||||
"ExtractFn",
|
||||
"RoleMeta",
|
||||
]) {
|
||||
expect(body).toContain(term);
|
||||
}
|
||||
|
||||
expect(body).toMatch(/type[\s\S]*interface/i);
|
||||
expect(body).toMatch(/function[\s\S]*class/i);
|
||||
expect(body).toContain("Crockford Base32");
|
||||
expect(body).toMatch(/no[\s\S]*default export/i);
|
||||
expect(body).toMatch(/no[\s\S]*console/i);
|
||||
expect(body).toMatch(/no[\s\S]*dynamic import/i);
|
||||
|
||||
expect(body).toContain("bun run check");
|
||||
expect(body).toContain("bun test");
|
||||
expect(body).toContain("uncaged-workflow");
|
||||
expect(body).toContain("bun build");
|
||||
expect(body).toContain("CLAUDE.md");
|
||||
expect(body).toContain("docs/architecture.md");
|
||||
});
|
||||
|
||||
test("errors when directory already exists", async () => {
|
||||
const first = await cmdInitWorkspace(parent, "dup");
|
||||
expect(first.ok).toBe(true);
|
||||
|
||||
const second = await cmdInitWorkspace(parent, "dup");
|
||||
expect(second.ok).toBe(false);
|
||||
if (!second.ok) {
|
||||
expect(second.error).toContain("already exists");
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const empty = await cmdInitWorkspace(parent, "");
|
||||
expect(empty.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("usage lists init subcommands", () => {
|
||||
const u = formatCliUsage();
|
||||
expect(u).toContain("init workspace <name>");
|
||||
expect(u).toContain("init template <name>");
|
||||
expect(u).toContain("Development:");
|
||||
});
|
||||
|
||||
test("runCli rejects unknown init subcommand", async () => {
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "bogus", "name"]);
|
||||
expect(code).toBe(1);
|
||||
});
|
||||
|
||||
test.serial("runCli init workspace uses cwd", async () => {
|
||||
const prev = process.cwd();
|
||||
try {
|
||||
process.chdir(parent);
|
||||
const code = await runCli(join(parent, "_storage"), ["init", "workspace", "from-cli"]);
|
||||
expect(code).toBe(0);
|
||||
expect(await pathExists(join(parent, "from-cli", "workflows", "package.json"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(prev);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
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 {
|
||||
formatLiveDebugLine,
|
||||
formatLiveTimeLabel,
|
||||
LIVE_CONTENT_MAX_LINES,
|
||||
type LiveRoleRow,
|
||||
renderLiveRoleStepLines,
|
||||
} from "../src/commands/thread/index.js";
|
||||
import { parseLiveArgv } from "../src/live-argv.js";
|
||||
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
describe("live helpers", () => {
|
||||
test("formatLiveTimeLabel pads HH:MM:SS", () => {
|
||||
const label = formatLiveTimeLabel(new Date("2024-06-01T09:08:07.000Z").getTime());
|
||||
expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test("formatLiveDebugLine flattens newlines in message", () => {
|
||||
const line = formatLiveDebugLine(0, "TAG1", "a\nb");
|
||||
expect(line).toContain("[TAG1]");
|
||||
expect(line).toContain("a b");
|
||||
expect(line).not.toContain("\n");
|
||||
});
|
||||
|
||||
test("renderLiveRoleStepLines truncates content to LIVE_CONTENT_MAX_LINES", () => {
|
||||
const lines = Array.from({ length: LIVE_CONTENT_MAX_LINES + 3 }, (_, i) => `L${i + 1}`);
|
||||
const row: LiveRoleRow = {
|
||||
role: "r",
|
||||
content: lines.join("\n"),
|
||||
meta: { k: "v" },
|
||||
timestamp: 0,
|
||||
};
|
||||
const out = renderLiveRoleStepLines(row, "r");
|
||||
const body = out.filter((l) => l.startsWith(" L"));
|
||||
expect(body.length).toBe(LIVE_CONTENT_MAX_LINES);
|
||||
expect(out.some((l) => l.includes("more line"))).toBe(true);
|
||||
expect(out.some((l) => l.startsWith(" meta: "))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLiveArgv", () => {
|
||||
test("parses thread id and flags in any order", () => {
|
||||
const a = parseLiveArgv(["01ABC", "--debug", "--role", "planner"]);
|
||||
expect(a.ok).toBe(true);
|
||||
if (a.ok) {
|
||||
expect(a.value.threadId).toBe("01ABC");
|
||||
expect(a.value.latest).toBe(false);
|
||||
expect(a.value.debug).toBe(true);
|
||||
expect(a.value.role).toBe("planner");
|
||||
}
|
||||
const b = parseLiveArgv(["--latest", "--role", "x"]);
|
||||
expect(b.ok).toBe(true);
|
||||
if (b.ok) {
|
||||
expect(b.value.latest).toBe(true);
|
||||
expect(b.value.threadId).toBe(null);
|
||||
expect(b.value.role).toBe("x");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects --latest with thread id", () => {
|
||||
const r = parseLiveArgv(["--latest", "01ABC"]);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live CLI", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let storageRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
});
|
||||
|
||||
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("unknown thread id exits 1", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const r = spawnSync(process.execPath, [cliEntryPath, "live", "01UNKNOWNXXXXXXXXXXXXXXXXX"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
expect(String(r.stderr ?? "")).toContain("thread not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("live --latest with empty storage", () => {
|
||||
let prevEnv: string | undefined;
|
||||
let emptyRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
emptyRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-live-empty-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = emptyRoot;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevEnv === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = prevEnv;
|
||||
}
|
||||
await rm(emptyRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("exits 1 when no threads exist", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: emptyRoot };
|
||||
const r = spawnSync(process.execPath, [cliEntryPath, "live", "--latest"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(r.status).toBe(1);
|
||||
expect(String(r.stderr ?? "")).toContain("no threads");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow-cas";
|
||||
|
||||
import { createApp } from "../src/commands/serve/app.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
function buildApp(storageRoot: string) {
|
||||
const app = createApp(storageRoot);
|
||||
return {
|
||||
fetch: (path: string, init?: RequestInit) =>
|
||||
app.fetch(new Request(`http://localhost${path}`, init)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("serve /healthz", () => {
|
||||
test("returns ok", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/healthz");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/workflows", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/workflows");
|
||||
// Registry file won't exist, should return error
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing thread", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/nonexistent-id");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/threads/running", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads/running");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { threads: unknown[] };
|
||||
expect(body.threads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve /api/cas", () => {
|
||||
test("returns empty list for missing storage", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { hashes: unknown[] };
|
||||
expect(body.hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns 404 for missing hash", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas/nonexistent-hash");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve error handling", () => {
|
||||
test("POST /api/threads with invalid JSON body → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("invalid JSON body");
|
||||
});
|
||||
|
||||
test("POST /api/cas with invalid JSON body → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "not json",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("invalid JSON body");
|
||||
});
|
||||
|
||||
test("POST /api/threads with missing required fields → 400", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res = await fetch("/api/threads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ foo: "bar" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toContain("required");
|
||||
});
|
||||
|
||||
test("global error handler returns 500 with JSON", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
app.get("/test-error", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
const res = await app.fetch(new Request("http://localhost/test-error"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve security", () => {
|
||||
test("CORS headers present on responses", async () => {
|
||||
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const res2 = await app.fetch(
|
||||
new Request("http://localhost/healthz", {
|
||||
headers: { Origin: "http://localhost:5173" },
|
||||
}),
|
||||
);
|
||||
expect(res2.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
|
||||
});
|
||||
|
||||
test("POST with body > 1MB → 413", async () => {
|
||||
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
|
||||
const largeBody = "x".repeat(1_048_577);
|
||||
const res = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(largeBody.length),
|
||||
},
|
||||
body: largeBody,
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("Payload too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serve CAS round-trip", () => {
|
||||
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
|
||||
|
||||
test("put then get", async () => {
|
||||
const { fetch } = buildApp(tmpDir);
|
||||
|
||||
const putRes = await fetch("/api/cas", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "hello world" }),
|
||||
});
|
||||
expect(putRes.status).toBe(201);
|
||||
const putBody = (await putRes.json()) as { hash: string };
|
||||
expect(typeof putBody.hash).toBe("string");
|
||||
|
||||
const getRes = await fetch(`/api/cas/${putBody.hash}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
const getBody = (await getRes.json()) as { content: string };
|
||||
expect(getBody.content).toBe(casStoredForm("hello world"));
|
||||
|
||||
// cleanup
|
||||
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
|
||||
expect(delRes.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
import { resolveWorkflowStorageRoot } from "../src/storage-env.js";
|
||||
|
||||
describe("resolveWorkflowStorageRoot", () => {
|
||||
let savedInternal: string | undefined;
|
||||
let savedUser: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedInternal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
savedUser = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedInternal === undefined) {
|
||||
delete process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = savedInternal;
|
||||
}
|
||||
if (savedUser === undefined) {
|
||||
delete process.env.WORKFLOW_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = savedUser;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns default when no env vars are set", () => {
|
||||
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||
});
|
||||
|
||||
test("WORKFLOW_STORAGE_ROOT overrides default", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/custom-storage";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/custom-storage");
|
||||
});
|
||||
|
||||
test("UNCAGED_WORKFLOW_STORAGE_ROOT takes priority over WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-path";
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "/tmp/internal-path";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/internal-path");
|
||||
});
|
||||
|
||||
test("ignores empty WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "";
|
||||
expect(resolveWorkflowStorageRoot()).toBe(getDefaultWorkflowStorageRoot());
|
||||
});
|
||||
|
||||
test("ignores empty UNCAGED_WORKFLOW_STORAGE_ROOT and falls through to WORKFLOW_STORAGE_ROOT", () => {
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = "";
|
||||
process.env.WORKFLOW_STORAGE_ROOT = "/tmp/user-fallback";
|
||||
expect(resolveWorkflowStorageRoot()).toBe("/tmp/user-fallback");
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,30 @@
|
||||
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 { getGlobalCasDir } from "@uncaged/workflow";
|
||||
import { cmdAdd } from "../src/cmd-add.js";
|
||||
import { cmdCasPut } from "../src/cmd-cas.js";
|
||||
import { cmdKill } from "../src/cmd-kill.js";
|
||||
import { cmdPause } from "../src/cmd-pause.js";
|
||||
import { cmdPs } from "../src/cmd-ps.js";
|
||||
import { cmdResume } from "../src/cmd-resume.js";
|
||||
import { cmdRun } from "../src/cmd-run.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||
import { cmdThreads } from "../src/cmd-threads.js";
|
||||
import { getBundleDir, readThreadsIndex } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { cmdCasPut } from "../src/commands/cas/index.js";
|
||||
import {
|
||||
cmdKill,
|
||||
cmdPause,
|
||||
cmdPs,
|
||||
cmdResume,
|
||||
cmdRun,
|
||||
cmdThreadRemove,
|
||||
cmdThreadShow,
|
||||
cmdThreads,
|
||||
} 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",
|
||||
@@ -31,18 +40,26 @@ const threadFixtureDescriptor = `export const descriptor = {
|
||||
`;
|
||||
|
||||
const fastBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const slowPlannerBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
@@ -50,59 +67,57 @@ export const run = async function* (input) {
|
||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
||||
|
||||
const abortablePlannerBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (input, options) {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "plan");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
h = await putContentMerkleNode(cas, "code");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const pauseResumeBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
yield { role: "first", content: "f", meta: {} };
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
let h = await putContentMerkleNode(cas, "f");
|
||||
yield { role: "first", contentHash: h, meta: {}, refs: [h] };
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
yield { role: "second", content: "s", meta: {} };
|
||||
h = await putContentMerkleNode(cas, "s");
|
||||
yield { role: "second", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "done" };
|
||||
};
|
||||
`;
|
||||
|
||||
const delayedFirstYieldBundleSource = `${threadFixtureDescriptor}
|
||||
export const run = async function* (input) {
|
||||
${wfPutImport}
|
||||
export const run = async function* (_input, options) {
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
yield { role: "only", content: "x", meta: {} };
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "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));
|
||||
@@ -117,6 +132,7 @@ describe("cli thread commands", () => {
|
||||
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
|
||||
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
|
||||
await ensureTestWorkflowRegistryConfig(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -173,8 +189,7 @@ describe("cli thread commands", () => {
|
||||
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 () => {
|
||||
@@ -207,11 +222,11 @@ 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, threadId, "keep-after-thread-rm");
|
||||
const put = await cmdCasPut(storageRoot, "keep-after-thread-rm");
|
||||
expect(put.ok).toBe(true);
|
||||
if (!put.ok) {
|
||||
return;
|
||||
@@ -228,13 +243,16 @@ describe("cli thread commands", () => {
|
||||
|
||||
test("cli entrypoint dispatches threads / ps (spawn)", () => {
|
||||
const env = { ...process.env, UNCAGED_WORKFLOW_STORAGE_ROOT: storageRoot };
|
||||
const threads = spawnSync(process.execPath, [cliEntryPath, "threads"], {
|
||||
const threads = spawnSync(process.execPath, [cliEntryPath, "thread", "list"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(threads.status).toBe(0);
|
||||
|
||||
const ps = spawnSync(process.execPath, [cliEntryPath, "ps"], { env, encoding: "utf8" });
|
||||
const ps = spawnSync(process.execPath, [cliEntryPath, "thread", "ps"], {
|
||||
env,
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(ps.status).toBe(0);
|
||||
});
|
||||
|
||||
@@ -293,24 +311,20 @@ describe("cli thread commands", () => {
|
||||
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(2);
|
||||
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));
|
||||
@@ -326,24 +340,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, 3, 120);
|
||||
expect(await countDataJsonlLines(dataPath)).toBe(3);
|
||||
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);
|
||||
});
|
||||
@@ -367,8 +390,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);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Minimal valid global config so {@link executeThread} can resolve the extract scene (CLI integration tests). */
|
||||
export const TEST_WORKFLOW_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
export async function ensureTestWorkflowRegistryConfig(storageRoot: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), TEST_WORKFLOW_REGISTRY_YAML, "utf8");
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
{
|
||||
"name": "@uncaged/cli-workflow",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"uncaged-workflow": "src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "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": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
|
||||
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function shouldUseColor(): boolean {
|
||||
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
|
||||
}
|
||||
|
||||
export function highlightLiveRole(name: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return name;
|
||||
}
|
||||
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
|
||||
}
|
||||
|
||||
export function dimGreyLine(line: string): string {
|
||||
if (!shouldUseColor()) {
|
||||
return line;
|
||||
}
|
||||
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
|
||||
export type CommandEntry = {
|
||||
handler: DispatchFn;
|
||||
args: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type CommandGroup = {
|
||||
name: string;
|
||||
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
|
||||
};
|
||||
|
||||
export type DispatchGroupFn = (
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
) => Promise<number> | null;
|
||||
@@ -1,430 +1,94 @@
|
||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
|
||||
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js";
|
||||
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "./cmd-cas.js";
|
||||
import { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
||||
import { cmdGc } from "./cmd-gc.js";
|
||||
import { cmdHistory } from "./cmd-history.js";
|
||||
import { cmdKill } from "./cmd-kill.js";
|
||||
import { cmdList, formatListLines } from "./cmd-list.js";
|
||||
import { cmdPause } from "./cmd-pause.js";
|
||||
import { cmdPs } from "./cmd-ps.js";
|
||||
import { cmdRemove } from "./cmd-remove.js";
|
||||
import { cmdResume } from "./cmd-resume.js";
|
||||
import { cmdRollback } from "./cmd-rollback.js";
|
||||
import { cmdRun } from "./cmd-run.js";
|
||||
import { cmdShow, formatShowYaml } from "./cmd-show.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "./cmd-thread.js";
|
||||
import { cmdThreads } from "./cmd-threads.js";
|
||||
import { parseRunArgv } from "./run-argv.js";
|
||||
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
|
||||
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 { createInitDispatcher } from "./commands/init/index.js";
|
||||
import { dispatchServe } from "./commands/serve/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";
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
|
||||
" uncaged-workflow list",
|
||||
" uncaged-workflow show <name>",
|
||||
" uncaged-workflow remove <name>",
|
||||
" uncaged-workflow run <name> [--prompt <text>] [--max-rounds N]",
|
||||
" uncaged-workflow ps",
|
||||
" uncaged-workflow kill <thread-id>",
|
||||
" uncaged-workflow history <name>",
|
||||
" uncaged-workflow rollback <name> [hash]",
|
||||
" uncaged-workflow pause <thread-id>",
|
||||
" uncaged-workflow resume <thread-id>",
|
||||
" uncaged-workflow threads [name]",
|
||||
" uncaged-workflow thread <id>",
|
||||
" uncaged-workflow thread rm <id>",
|
||||
" uncaged-workflow fork <thread-id> [--from-role <role>]",
|
||||
" uncaged-workflow gc",
|
||||
" uncaged-workflow cas get <thread-id> <hash>",
|
||||
" uncaged-workflow cas put <thread-id> <content>",
|
||||
" uncaged-workflow cas list <thread-id>",
|
||||
" uncaged-workflow cas rm <thread-id> <hash>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseAddArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdAdd(storageRoot, parsed.value);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const w of result.value.warnings) {
|
||||
printCliWarn(w);
|
||||
}
|
||||
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usage()}\n\nerror: list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of formatListLines(result.value)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: show requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdShow(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatShowYaml(name, result.value));
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: remove requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdRemove(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed workflow "${name}" from registry`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usage()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`kill sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: history requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdHistory(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 2) {
|
||||
printCliError(`${usage()}\n\nerror: rollback requires <name> [hash]`);
|
||||
return 1;
|
||||
}
|
||||
const hashArg = argv[1];
|
||||
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`rolled back workflow "${name}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`pause sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`resume sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreads(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = await cmdThreads(storageRoot, argv);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: thread requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed thread ${id}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchThreadBranch(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const sub = rest[0];
|
||||
if (sub === "rm") {
|
||||
return dispatchThreadRm(storageRoot, rest.slice(1));
|
||||
}
|
||||
return dispatchThread(storageRoot, rest);
|
||||
}
|
||||
|
||||
async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usage()}\n\nerror: gc takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdGc(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
const stats = result.value;
|
||||
printCliLine(
|
||||
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usage()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
const hash = rest[1];
|
||||
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
||||
printCliError(`${usage()}\n\nerror: cas get requires <thread-id> <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasGet(storageRoot, threadId, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
const content = rest[1];
|
||||
if (threadId === undefined || content === undefined || rest.length > 2) {
|
||||
printCliError(`${usage()}\n\nerror: cas put requires <thread-id> <content>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasPut(storageRoot, threadId, content);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
if (threadId === undefined || rest.length > 1) {
|
||||
printCliError(`${usage()}\n\nerror: cas list requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasList(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const hash of result.value) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const threadId = rest[0];
|
||||
const hash = rest[1];
|
||||
if (threadId === undefined || hash === undefined || rest.length > 2) {
|
||||
printCliError(`${usage()}\n\nerror: cas rm requires <thread-id> <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasRm(storageRoot, threadId, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed cas entry ${hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const CAS_SUBCOMMAND_TABLE: Record<
|
||||
string,
|
||||
(storageRoot: string, rest: string[]) => Promise<number>
|
||||
> = {
|
||||
get: dispatchCasGet,
|
||||
put: dispatchCasPut,
|
||||
list: dispatchCasList,
|
||||
rm: dispatchCasRm,
|
||||
};
|
||||
|
||||
async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
function dispatchGroup(
|
||||
tableName: string,
|
||||
table: Record<string, CommandEntry>,
|
||||
storageRoot: string,
|
||||
argv: string[],
|
||||
): Promise<number> | null {
|
||||
const sub = argv[0];
|
||||
if (sub === undefined) {
|
||||
printCliError(`${usage()}\n\nerror: unknown cas subcommand: (none)`);
|
||||
return 1;
|
||||
if (sub === undefined || sub === "--help" || sub === "-h") {
|
||||
const entries = Object.entries(table);
|
||||
const lines = [`${tableName} subcommands:\n`];
|
||||
for (const [name, e] of entries) {
|
||||
const args = e.args ? ` ${e.args}` : "";
|
||||
lines.push(` uncaged-workflow ${tableName} ${name}${args}`);
|
||||
lines.push(` ${e.description}\n`);
|
||||
}
|
||||
printCliLine(lines.join("\n"));
|
||||
return Promise.resolve(sub === undefined ? 1 : 0);
|
||||
}
|
||||
const handler = CAS_SUBCOMMAND_TABLE[sub];
|
||||
if (handler === undefined) {
|
||||
printCliError(`${usage()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
const entry = table[sub];
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
return handler(storageRoot, argv.slice(1));
|
||||
return entry.handler(storageRoot, argv.slice(1));
|
||||
}
|
||||
|
||||
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
|
||||
export function formatCliUsage(): string {
|
||||
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
|
||||
}
|
||||
|
||||
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
|
||||
const dispatchThread = createThreadDispatcher({ dispatchGroup });
|
||||
const dispatchCas = createCasDispatcher({ dispatchGroup });
|
||||
const dispatchInit = createInitDispatcher({ dispatchGroup });
|
||||
|
||||
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
|
||||
if (topic === undefined) {
|
||||
printCliLine(formatSkillIndex());
|
||||
return 0;
|
||||
}
|
||||
const doc = formatSkillTopic(topic);
|
||||
if (doc === null) {
|
||||
printCliError(`unknown skill topic: ${topic}\n\n${formatSkillIndex()}`);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(doc);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
return showSkillDocOrIndex(argv[0]);
|
||||
}
|
||||
|
||||
const COMMAND_TABLE: Record<string, DispatchFn> = {
|
||||
add: dispatchAdd,
|
||||
list: dispatchList,
|
||||
show: dispatchShow,
|
||||
remove: dispatchRemove,
|
||||
run: dispatchRun,
|
||||
ps: dispatchPs,
|
||||
kill: dispatchKill,
|
||||
history: dispatchHistory,
|
||||
rollback: dispatchRollback,
|
||||
pause: dispatchPause,
|
||||
resume: dispatchResume,
|
||||
threads: dispatchThreads,
|
||||
thread: dispatchThreadBranch,
|
||||
fork: dispatchFork,
|
||||
gc: dispatchGc,
|
||||
workflow: dispatchWorkflow,
|
||||
thread: dispatchThread,
|
||||
cas: dispatchCas,
|
||||
init: dispatchInit,
|
||||
skill: dispatchSkill,
|
||||
run: dispatchRun,
|
||||
live: dispatchLive,
|
||||
serve: dispatchServe,
|
||||
};
|
||||
|
||||
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length === 0) {
|
||||
printCliError(usage());
|
||||
printCliLine(formatCliUsage());
|
||||
return 1;
|
||||
}
|
||||
const command = argv[0];
|
||||
if (command === undefined) {
|
||||
printCliError(usage());
|
||||
printCliLine(formatCliUsage());
|
||||
return 1;
|
||||
}
|
||||
const rest = argv.slice(1);
|
||||
|
||||
const dispatch = COMMAND_TABLE[command];
|
||||
if (dispatch === undefined) {
|
||||
printCliError(`${usage()}\n\nerror: unknown command ${command}`);
|
||||
return 1;
|
||||
if (dispatch !== undefined) {
|
||||
return dispatch(storageRoot, rest);
|
||||
}
|
||||
return dispatch(storageRoot, rest);
|
||||
|
||||
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
|
||||
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/index.js";
|
||||
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/index.js";
|
||||
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/index.js";
|
||||
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/index.js";
|
||||
|
||||
export function getCommandRegistry(): ReadonlyArray<CommandGroup> {
|
||||
return [
|
||||
{
|
||||
name: "workflow",
|
||||
commands: Object.entries(WORKFLOW_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "thread",
|
||||
commands: Object.entries(THREAD_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "cas",
|
||||
commands: Object.entries(CAS_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "init",
|
||||
commands: Object.entries(INIT_SUBCOMMAND_TABLE).map(([name, e]) => ({
|
||||
name,
|
||||
args: e.args,
|
||||
description: e.description,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
setCommandGroupsForUsage(getCommandRegistry());
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
|
||||
let commandGroupsForUsage: ReadonlyArray<CommandGroup> | null = null;
|
||||
|
||||
export function setCommandGroupsForUsage(groups: ReadonlyArray<CommandGroup>): void {
|
||||
commandGroupsForUsage = groups;
|
||||
}
|
||||
|
||||
export function getCommandGroupsForUsage(): ReadonlyArray<CommandGroup> {
|
||||
if (commandGroupsForUsage === null) {
|
||||
throw new Error("BUG: command groups for usage not initialized");
|
||||
}
|
||||
return commandGroupsForUsage;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { CommandGroup } from "./cli-command-types.js";
|
||||
|
||||
/** Keep aligned with `getSkillTopics()` in skill.ts (names only, for error usage lines). */
|
||||
export const USAGE_SKILL_TOPIC_ROWS: ReadonlyArray<{ name: string }> = [
|
||||
{ name: "cli" },
|
||||
{ name: "develop" },
|
||||
{ name: "author" },
|
||||
];
|
||||
|
||||
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
|
||||
workflow: "Workflow registry:",
|
||||
thread: "Thread execution:",
|
||||
cas: "Content-addressable storage:",
|
||||
init: "Development:",
|
||||
};
|
||||
|
||||
export function formatUsageCommandLines(
|
||||
rows: ReadonlyArray<{ prefix: string; description: string }>,
|
||||
): string[] {
|
||||
const maxPrefix = rows.reduce((max, row) => Math.max(max, row.prefix.length), 0);
|
||||
const gap = 2;
|
||||
return rows.map((row) => {
|
||||
const pad = " ".repeat(maxPrefix - row.prefix.length + gap);
|
||||
return ` ${row.prefix}${pad}${row.description}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCliUsage(
|
||||
groups: ReadonlyArray<CommandGroup>,
|
||||
skillTopics: ReadonlyArray<{ name: string }>,
|
||||
): string {
|
||||
const lines: string[] = ["uncaged-workflow — workflow engine CLI", ""];
|
||||
|
||||
for (const group of groups) {
|
||||
const sectionTitle = USAGE_SECTION_BY_GROUP[group.name];
|
||||
if (sectionTitle === undefined) {
|
||||
throw new Error(`BUG: missing usage section title for group "${group.name}"`);
|
||||
}
|
||||
lines.push(sectionTitle);
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const args = cmd.args ? ` ${cmd.args}` : "";
|
||||
return {
|
||||
prefix: `${group.name} ${cmd.name}${args}`,
|
||||
description: cmd.description,
|
||||
};
|
||||
});
|
||||
lines.push(...formatUsageCommandLines(rows));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("Shortcuts:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{ prefix: "run <name> [...]", description: "→ thread run" },
|
||||
{ prefix: "live <id> [...]", description: "→ thread live" },
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Server:");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "serve [--port N] [--host ADDR]",
|
||||
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("Reference:");
|
||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||
lines.push(
|
||||
...formatUsageCommandLines([
|
||||
{
|
||||
prefix: "skill [topic]",
|
||||
description: `Agent-consumable docs (${skillTopicNames})`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Use <command> --help for subcommand details.");
|
||||
lines.push("");
|
||||
lines.push("Environment variables:");
|
||||
lines.push(
|
||||
" WORKFLOW_STORAGE_ROOT Override storage directory (default: ~/.uncaged/workflow)",
|
||||
);
|
||||
lines.push(
|
||||
" UNCAGED_WORKFLOW_STORAGE_ROOT Internal override (takes priority over WORKFLOW_STORAGE_ROOT)",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
Regular → Executable
@@ -1,43 +0,0 @@
|
||||
import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
content: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hash = await cas.put(content);
|
||||
return ok(hash);
|
||||
}
|
||||
|
||||
export async function cmdCasList(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hashes = await cas.list();
|
||||
return ok(hashes);
|
||||
}
|
||||
|
||||
export async function cmdCasRm(
|
||||
storageRoot: string,
|
||||
_threadId: string,
|
||||
hash: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
await cas.delete(hash);
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildForkPlan, err, generateUlid, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
|
||||
export function parseForkArgv(
|
||||
argv: string[],
|
||||
): Result<{ threadId: string; fromRole: string | null }, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
|
||||
export async function cmdFork(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
fromRole: string | null,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
}
|
||||
|
||||
const plan = buildForkPlan(text, 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,
|
||||
content: s.content,
|
||||
meta: s.meta,
|
||||
timestamp: s.timestamp,
|
||||
}));
|
||||
|
||||
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,
|
||||
},
|
||||
{ awaitResponseLine: false },
|
||||
);
|
||||
if (!sent.ok) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
return ok({ threadId: newThreadId });
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { type GcResult, garbageCollectCas, type Result } from "@uncaged/workflow";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdKill(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "kill", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdPause(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "pause", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import {
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
type WorkerCtl,
|
||||
} from "./worker-spawn.js";
|
||||
|
||||
export async function cmdResume(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlPath = join(storageRoot, "workers", `${hashResult.value}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hashResult.value}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctl.port,
|
||||
{ type: "resume", threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { err, garbageCollectCas, ok, type Result } from "@uncaged/workflow";
|
||||
|
||||
import { readTextFileIfExists } from "./fs-utils.js";
|
||||
import { resolveThreadDataPath } from "./thread-scan.js";
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
const text = await readTextFileIfExists(dataPath);
|
||||
if (text === null) {
|
||||
return err(`thread data missing: ${threadId}`);
|
||||
}
|
||||
return ok(text.endsWith("\n") ? text.slice(0, -1) : text);
|
||||
}
|
||||
|
||||
export async function cmdThreadRemove(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||
if (dataPath === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
const dir = dirname(dataPath);
|
||||
const infoPath = join(dir, `${threadId}.info.jsonl`);
|
||||
const runningPath = join(dir, `${threadId}.running`);
|
||||
|
||||
await unlink(dataPath);
|
||||
await unlink(infoPath).catch(() => {});
|
||||
await unlink(runningPath).catch(() => {});
|
||||
|
||||
await garbageCollectCas(storageRoot);
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdGc } from "./gc.js";
|
||||
import { cmdCasGet } from "./get.js";
|
||||
import { cmdCasList } from "./list.js";
|
||||
import { cmdCasPut } from "./put.js";
|
||||
import { cmdCasRm } from "./rm.js";
|
||||
import type { CasDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: gc takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdGc(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
const stats = result.value;
|
||||
printCliLine(
|
||||
`scanned ${stats.scannedThreads} threads, ${stats.activeRefs} active refs, deleted ${stats.deletedEntries} entries`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasGet(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const hash = rest[0];
|
||||
if (hash === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas get requires <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasGet(storageRoot, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasPut(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const content = rest[0];
|
||||
if (content === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas put requires <content>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasPut(storageRoot, content);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasList(storageRoot: string, rest: string[]): Promise<number> {
|
||||
if (rest.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: cas list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const hash of result.value) {
|
||||
printCliLine(hash);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchCasRm(storageRoot: string, rest: string[]): Promise<number> {
|
||||
const hash = rest[0];
|
||||
if (hash === undefined || rest.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: cas rm requires <hash>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdCasRm(storageRoot, hash);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed cas entry ${hash}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
get: {
|
||||
handler: dispatchCasGet,
|
||||
args: "<hash>",
|
||||
description: "Retrieve content by hash from CAS",
|
||||
},
|
||||
put: {
|
||||
handler: dispatchCasPut,
|
||||
args: "<content>",
|
||||
description: "Store content in CAS, prints hash",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchCasList,
|
||||
args: "",
|
||||
description: "List all hashes in CAS",
|
||||
},
|
||||
rm: { handler: dispatchCasRm, args: "<hash>", description: "Remove a CAS entry by hash" },
|
||||
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
|
||||
};
|
||||
|
||||
export function createCasDispatcher(deps: CasDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchCas(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("cas", CAS_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown cas subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type GcResult, garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export async function cmdGc(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
return garbageCollectCas(storageRoot);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasGet(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return err(`cas entry not found: ${hash}`);
|
||||
}
|
||||
return ok(content);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
CAS_SUBCOMMAND_TABLE,
|
||||
createCasDispatcher,
|
||||
dispatchCasGet,
|
||||
dispatchCasList,
|
||||
dispatchCasPut,
|
||||
dispatchCasRm,
|
||||
dispatchGc,
|
||||
} from "./dispatch.js";
|
||||
export { cmdGc } from "./gc.js";
|
||||
export { cmdCasGet } from "./get.js";
|
||||
export { cmdCasList } from "./list.js";
|
||||
export { cmdCasPut } from "./put.js";
|
||||
export { cmdCasRm } from "./rm.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hashes = await cas.list();
|
||||
return ok(hashes);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasPut(
|
||||
storageRoot: string,
|
||||
content: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const hash = await cas.put(content);
|
||||
return ok(hash);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
export async function cmdCasRm(storageRoot: string, hash: string): Promise<Result<void, string>> {
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
await cas.delete(hash);
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type CasDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdInitTemplate } from "./template.js";
|
||||
import type { InitDispatchDeps } from "./types.js";
|
||||
import { cmdInitWorkspace } from "./workspace.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: init workspace requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitWorkspace(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized workflow workspace at ${result.value.rootPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: init template requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdInitTemplate(process.cwd(), name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`initialized template at ${result.value.templatePath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
workspace: {
|
||||
handler: dispatchInitWorkspace,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow workspace",
|
||||
},
|
||||
template: {
|
||||
handler: dispatchInitTemplate,
|
||||
args: "<name>",
|
||||
description: "Initialize a new workflow template",
|
||||
},
|
||||
};
|
||||
|
||||
export function createInitDispatcher(deps: InitDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchInit(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("init", INIT_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown init subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
createInitDispatcher,
|
||||
dispatchInitTemplate,
|
||||
dispatchInitWorkspace,
|
||||
INIT_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdInitTemplate } from "./template.js";
|
||||
export type { CmdInitTemplateSuccess, CmdInitWorkspaceSuccess } from "./types.js";
|
||||
export { cmdInitWorkspace } from "./workspace.js";
|
||||
@@ -0,0 +1,94 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
|
||||
import {
|
||||
templateIndexTs,
|
||||
templateModeratorTs,
|
||||
templatePackageJson,
|
||||
templateRolesTs,
|
||||
templateTsconfigJson,
|
||||
} from "./templates.js";
|
||||
import type { CmdInitTemplateSuccess } from "./types.js";
|
||||
import { validateWorkspaceSegment } from "./validate.js";
|
||||
|
||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||
}
|
||||
|
||||
async function readPackageJsonWorkspaces(dir: string): Promise<unknown | null> {
|
||||
const pkgPath = join(dir, "package.json");
|
||||
if (!(await pathExists(pkgPath))) {
|
||||
return null;
|
||||
}
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(pkgPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || !("workspaces" in parsed)) {
|
||||
return null;
|
||||
}
|
||||
return (parsed as { workspaces: unknown }).workspaces;
|
||||
}
|
||||
|
||||
/** Resolve uncaged-workflow workspace root (package.json with `templates/*` in `workspaces`). */
|
||||
async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<string, string>> {
|
||||
let dir = resolve(startDir);
|
||||
for (;;) {
|
||||
const workspaces = await readPackageJsonWorkspaces(dir);
|
||||
if (workspaces !== null && hasTemplatesWorkspaceGlob(workspaces)) {
|
||||
return ok(dir);
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) {
|
||||
return err(
|
||||
'not inside a workflow workspace (no package.json with workspaces containing "templates/*")',
|
||||
);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdInitTemplate(
|
||||
startDir: string,
|
||||
templateName: string,
|
||||
): Promise<Result<CmdInitTemplateSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(templateName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
}
|
||||
|
||||
const rootResult = await findWorkflowWorkspaceRoot(startDir);
|
||||
if (!rootResult.ok) {
|
||||
return rootResult;
|
||||
}
|
||||
|
||||
const workspaceRoot = rootResult.value;
|
||||
const templateDir = join(workspaceRoot, "templates", templateName);
|
||||
if (await pathExists(templateDir)) {
|
||||
return err(`template already exists: ${templateDir}`);
|
||||
}
|
||||
|
||||
await mkdir(join(templateDir, "src"), { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(templateDir, "package.json"), templatePackageJson(templateName), "utf8"),
|
||||
writeFile(join(templateDir, "tsconfig.json"), templateTsconfigJson(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "roles.ts"), templateRolesTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "moderator.ts"), templateModeratorTs(), "utf8"),
|
||||
writeFile(join(templateDir, "src", "index.ts"), templateIndexTs(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ templatePath: templateDir });
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
export function templatePackageJson(templateName: string): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: `template-${templateName}`,
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function templateTsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
extends: "../../tsconfig.json",
|
||||
compilerOptions: {
|
||||
rootDir: "src",
|
||||
outDir: "dist",
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
export function templateRolesTs(): string {
|
||||
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||
"Minimal starter template: one greeter role, then END.";
|
||||
|
||||
export type HelloTemplateMeta = {
|
||||
greeter: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
const greeterMetaSchema = z.object({
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
import type { HelloTemplateMeta } from "./roles.js";
|
||||
|
||||
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
||||
ctx: ModeratorContext<HelloTemplateMeta>,
|
||||
) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "greeter";
|
||||
}
|
||||
return END;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
export function templateIndexTs(): string {
|
||||
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { helloTemplateModerator } from "./moderator.js";
|
||||
import {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
|
||||
export {
|
||||
HELLO_TEMPLATE_DESCRIPTION,
|
||||
type HelloTemplateMeta,
|
||||
greeterRole,
|
||||
} from "./roles.js";
|
||||
export { helloTemplateModerator } from "./moderator.js";
|
||||
|
||||
export const helloTemplateWorkflowDefinition: WorkflowDefinition<HelloTemplateMeta> = {
|
||||
description: HELLO_TEMPLATE_DESCRIPTION,
|
||||
roles: {
|
||||
greeter: greeterRole,
|
||||
},
|
||||
moderator: helloTemplateModerator,
|
||||
};
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type CmdInitTemplateSuccess = {
|
||||
templatePath: string;
|
||||
};
|
||||
|
||||
export type CmdInitWorkspaceSuccess = {
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
export type InitDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||
if (name.length === 0) {
|
||||
return err("workspace name must not be empty");
|
||||
}
|
||||
if (name === "." || name === "..") {
|
||||
return err("invalid workspace name");
|
||||
}
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
return err("workspace name must not contain path separators");
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } 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(
|
||||
{
|
||||
name: workspaceName,
|
||||
private: true,
|
||||
type: "module",
|
||||
workspaces: ["templates/*", "workflows"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function workflowsPackageJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
name: "workflows",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@uncaged/workflow-runtime": "^0.1.0",
|
||||
zod: "^4.0.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function biomeJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
$schema: "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||
files: {
|
||||
includes: ["**", "!**/node_modules", "!**/dist"],
|
||||
},
|
||||
formatter: {
|
||||
indentWidth: 2,
|
||||
},
|
||||
linter: {
|
||||
enabled: true,
|
||||
rules: {
|
||||
recommended: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function tsconfigJson(): string {
|
||||
return `${JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
target: "ESNext",
|
||||
module: "ESNext",
|
||||
moduleResolution: "Bundler",
|
||||
skipLibCheck: true,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
}
|
||||
|
||||
function agentsMd(): string {
|
||||
return `# AGENTS — Workflow 工作区开发指南
|
||||
|
||||
面向在本仓库中编写 workflow 的 coding agent。引擎层术语与架构细节与 **@uncaged/workflow** 上游文档一致,编写时可对照 \`CLAUDE.md\` 与 \`docs/architecture.md\`。
|
||||
|
||||
## 1. 项目结构(workspace / template / workflow instance)
|
||||
|
||||
| 层级 | 目录 / 产物 | 职责 |
|
||||
|------|----------------|------|
|
||||
| **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\` 命名导出) |
|
||||
|
||||
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 都可使用)。
|
||||
|
||||
引擎循环简述:**Moderator** → 选角色 → **Agent** 产出文本 → **Extract** 写入 **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\`。
|
||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||
|
||||
## 4. 编码规范
|
||||
|
||||
与 **CLAUDE.md** 对齐,摘要如下:
|
||||
|
||||
- **Functional-first**:优先 \`function\` + \`type\`,避免面向对象业务模型。
|
||||
- **type 而非 interface**:类型别名一律用 \`type\`,不要使用 \`interface\`。
|
||||
- **显式可空**:不要用 \`?:\`;可空字段写成 \`T | null\`。
|
||||
- **function 而非 class**:不用 class(第三方库要求或 \`Error\` 子类除外)。
|
||||
- **Crockford Base32**:日志 tag、bundle hash、thread id 等标识约定(引擎侧);工作区内自定义日志若沿用引擎 logger,tag 为 8 字符 Crockford Base32,且每个调用点唯一。
|
||||
- **Named exports only**:不要使用 **default export**;workflow bundle 须 **export const run** 与 **export const descriptor**。
|
||||
- **No console.log**:库代码用结构化 logger;CLI 用户输出可按项目 Biome 规则例外标注。
|
||||
- **No dynamic import**:业务与 bundle 内禁止 \`import()\`;例外仅限「运行时路径由用户提供的 bundle 加载器」(引擎内部)。
|
||||
|
||||
## 5. Template 复用
|
||||
|
||||
- **已发布模板**:可通过 npm 依赖 \`@uncaged/workflow-template-*\` 等包,在 workflow 实例中 import 其 **WorkflowDefinition** 再绑定 Agent。
|
||||
- **本地模板**:放在本仓库 \`templates/<name>/\`,由 workspace 协议引用(如 \`"template-foo": "workspace:*"\` 或相对路径),便于同源修改与版本控制。
|
||||
|
||||
选择模板时保持 **definition 与 agent 绑定分离**:模板只描述「做什么、顺序如何」,实例决定「谁执行、如何抽取 meta」。
|
||||
|
||||
## 6. Build and Test
|
||||
|
||||
日常命令:
|
||||
|
||||
\`\`\`sh
|
||||
bun install
|
||||
bun run check # Biome:lint + format
|
||||
bun test
|
||||
bun build # 若包内配置了 build 脚本则用于产出 dist / bundle
|
||||
uncaged-workflow add <name> <path/to/bundle.esm.js>
|
||||
\`\`\`
|
||||
|
||||
提交前至少运行 **bun run check** 与 **bun test**;registry 与本地运行流参见 README 与 CLI 文档。
|
||||
|
||||
## 7. 常见陷阱
|
||||
|
||||
- **No dynamic import**:bundle 须静态可分析;动态 \`import()\` 会破坏哈希与加载约束。
|
||||
- **No default export**:引擎只接受命名导出 \`run\` / \`descriptor\`。
|
||||
- **No console.log**:避免在可被 Biome \`noConsole\` 规则覆盖的代码路径直接使用 console。
|
||||
- **Single-file ESM bundle**:交付物是单一 \`.esm.js\`;静态 import 仅限 Node 内置(见 architecture 文档中的 Bundle Contract)。
|
||||
|
||||
---
|
||||
|
||||
编写新 workflow 时,先对齐 **RoleMeta → RoleDefinition(Zod)→ Moderator → 绑定 → 单文件 bundle**,再对照本节规范自检。
|
||||
`;
|
||||
}
|
||||
|
||||
function readmeMd(workspaceName: string): string {
|
||||
return `# ${workspaceName}
|
||||
|
||||
Local workflow development workspace (Bun monorepo).
|
||||
|
||||
## Layout
|
||||
|
||||
- \`templates/\` — reusable workflow definition packages (roles + moderator), no agent binding
|
||||
- \`workflows/\` — workflow instances that bind templates to agents and export \`run\` + \`descriptor\`
|
||||
|
||||
## Commands
|
||||
|
||||
\`\`\`sh
|
||||
bun install
|
||||
bun run check # after you add scripts / Biome
|
||||
uncaged-workflow add <name> <bundle.esm.js>
|
||||
uncaged-workflow run <name>
|
||||
\`\`\`
|
||||
|
||||
Create this skeleton with:
|
||||
|
||||
\`\`\`sh
|
||||
uncaged-workflow init workspace ${workspaceName}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
export async function cmdInitWorkspace(
|
||||
parentDir: string,
|
||||
workspaceName: string,
|
||||
): Promise<Result<CmdInitWorkspaceSuccess, string>> {
|
||||
const validated = validateWorkspaceSegment(workspaceName);
|
||||
if (!validated.ok) {
|
||||
return validated;
|
||||
}
|
||||
|
||||
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 Promise.all([
|
||||
writeFile(join(rootPath, "package.json"), rootPackageJson(workspaceName), "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, "templates", ".gitkeep"), "", "utf8"),
|
||||
writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"),
|
||||
]);
|
||||
|
||||
return ok({ rootPath });
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
|
||||
import { createCasRoutes } from "./routes-cas.js";
|
||||
import { createLiveRoutes } from "./routes-live.js";
|
||||
import { createThreadRoutes } from "./routes-thread.js";
|
||||
import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||
|
||||
const MAX_BODY_SIZE = 1_048_576; // 1 MB
|
||||
|
||||
export function createApp(storageRoot: string, agentToken: string | null): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.onError((_err, c) => {
|
||||
return c.json({ error: "Internal server error" }, 500);
|
||||
});
|
||||
|
||||
app.use(
|
||||
"*",
|
||||
cors({
|
||||
origin: [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:7860",
|
||||
"http://127.0.0.1:7860",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
if (c.req.method === "POST") {
|
||||
const contentLength = c.req.header("content-length");
|
||||
if (contentLength !== undefined && Number(contentLength) > MAX_BODY_SIZE) {
|
||||
return c.json({ error: "Payload too large" }, 413);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// ── Agent token auth (skip healthz) ───────────────────────────────
|
||||
if (agentToken !== null) {
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const token = c.req.header("X-Agent-Token");
|
||||
if (token !== agentToken) {
|
||||
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));
|
||||
app.route("/api/threads", createLiveRoutes(storageRoot));
|
||||
app.route("/api/cas", createCasRoutes(storageRoot));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { createApp } from "./app.js";
|
||||
export { dispatchServe, startServer } from "./serve.js";
|
||||
export type { ServeOptions } from "./types.js";
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { garbageCollectCas } from "@uncaged/workflow-execute";
|
||||
import { getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createCasRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
const casDir = getGlobalCasDir(storageRoot);
|
||||
const cas = createCasStore(casDir);
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const hashes = await cas.list();
|
||||
return c.json({ hashes });
|
||||
});
|
||||
|
||||
app.get("/:hash", async (c) => {
|
||||
const content = await cas.get(c.req.param("hash"));
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
return c.json({ hash: c.req.param("hash"), content });
|
||||
});
|
||||
|
||||
app.post("/", async (c) => {
|
||||
let body: { content: string };
|
||||
try {
|
||||
body = (await c.req.json()) as { content: string };
|
||||
} catch {
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
if (typeof body.content !== "string") {
|
||||
return c.json({ error: "content field required" }, 400);
|
||||
}
|
||||
const hash = await cas.put(body.content);
|
||||
return c.json({ hash }, 201);
|
||||
});
|
||||
|
||||
app.delete("/:hash", async (c) => {
|
||||
const hash = c.req.param("hash");
|
||||
const content = await cas.get(hash);
|
||||
if (content === null) {
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
await cas.delete(hash);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/gc", async (c) => {
|
||||
const result = await garbageCollectCas(storageRoot);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: result.error }, 500);
|
||||
}
|
||||
return c.json(result.value);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -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,200 @@
|
||||
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;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow-register";
|
||||
import { Hono } from "hono";
|
||||
|
||||
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const names = listRegisteredWorkflowNames(reg.value);
|
||||
const workflows = names.map((name) => {
|
||||
const entry = reg.value.workflows[name];
|
||||
return {
|
||||
name,
|
||||
hash: entry?.hash ?? null,
|
||||
timestamp: entry?.timestamp ?? null,
|
||||
};
|
||||
});
|
||||
return c.json({ workflows });
|
||||
});
|
||||
|
||||
app.get("/:name", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, ...entry });
|
||||
});
|
||||
|
||||
app.get("/:name/history", async (c) => {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return c.json({ error: reg.error.message }, 500);
|
||||
}
|
||||
const name = c.req.param("name");
|
||||
const entry = getRegisteredWorkflow(reg.value, name);
|
||||
if (entry === null) {
|
||||
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||
}
|
||||
return c.json({ name, history: entry.history });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as osHostname } from "node:os";
|
||||
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 {
|
||||
registerWithGateway,
|
||||
startHeartbeat,
|
||||
startTunnel,
|
||||
unregisterFromGateway,
|
||||
} from "./tunnel.js";
|
||||
import type { ServeOptions } from "./types.js";
|
||||
|
||||
const DEFAULT_GATEWAY_URL = "https://workflow-gateway.shazhou.workers.dev";
|
||||
const HEARTBEAT_INTERVAL_MS = 60_000;
|
||||
|
||||
export function startServer(
|
||||
storageRoot: string,
|
||||
options: ServeOptions,
|
||||
agentToken: string | null,
|
||||
): void {
|
||||
const app = createApp(storageRoot, agentToken);
|
||||
|
||||
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 requireNextArg(argv: string[], i: number, flag: string): Result<string, string> {
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined) {
|
||||
return err(`${flag} requires a value`);
|
||||
}
|
||||
return ok(next);
|
||||
}
|
||||
|
||||
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||
let port = 7860;
|
||||
let hostname = "127.0.0.1";
|
||||
let name = osHostname().split(".")[0].toLowerCase();
|
||||
let noTunnel = false;
|
||||
let gatewayUrl = DEFAULT_GATEWAY_URL;
|
||||
const gatewaySecret = process.env.WORKFLOW_GATEWAY_SECRET ?? "";
|
||||
const stringFlags: Record<string, (v: string) => void> = {
|
||||
"--host": (v) => {
|
||||
hostname = v;
|
||||
},
|
||||
"--name": (v) => {
|
||||
name = v;
|
||||
},
|
||||
"--gateway": (v) => {
|
||||
gatewayUrl = v;
|
||||
},
|
||||
};
|
||||
|
||||
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 === "--no-tunnel") {
|
||||
noTunnel = true;
|
||||
} else if (arg in stringFlags) {
|
||||
const r = requireNextArg(argv, i, arg);
|
||||
if (!r.ok) return r;
|
||||
stringFlags[arg](r.value);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ port, hostname, name, noTunnel, gatewayUrl, gatewaySecret });
|
||||
}
|
||||
|
||||
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseServeArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliLine(`error: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const options = parsed.value;
|
||||
const agentToken = options.noTunnel ? null : randomUUID();
|
||||
startServer(storageRoot, options, agentToken);
|
||||
|
||||
if (options.noTunnel) {
|
||||
printCliLine("tunnel disabled (--no-tunnel)");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Start cloudflared quick tunnel
|
||||
printCliLine("starting cloudflared quick tunnel...");
|
||||
const tunnel = await startTunnel(options.port);
|
||||
|
||||
if (!tunnel) {
|
||||
printCliLine("failed to create tunnel — continuing without gateway registration");
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
|
||||
printCliLine(`tunnel: ${tunnel.url}`);
|
||||
|
||||
// Register with gateway
|
||||
if (options.gatewaySecret) {
|
||||
const registered = await registerWithGateway(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
);
|
||||
if (registered) {
|
||||
printCliLine(`registered with gateway as "${options.name}"`);
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
const heartbeatTimer = startHeartbeat(
|
||||
options.gatewayUrl,
|
||||
options.name,
|
||||
tunnel.url,
|
||||
options.gatewaySecret,
|
||||
agentToken!,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
);
|
||||
|
||||
// Cleanup on exit
|
||||
const cleanup = async () => {
|
||||
clearInterval(heartbeatTimer);
|
||||
printCliLine("unregistering from gateway...");
|
||||
await unregisterFromGateway(options.gatewayUrl, options.name, options.gatewaySecret);
|
||||
tunnel.process.kill();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
} else {
|
||||
printCliLine("WORKFLOW_GATEWAY_SECRET not set — skipping gateway registration");
|
||||
}
|
||||
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { printCliLine } from "../../cli-output.js";
|
||||
|
||||
type TunnelHandle = {
|
||||
process: ReturnType<typeof Bun.spawn>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function startTunnel(port: number): Promise<TunnelHandle | null> {
|
||||
const proc = Bun.spawn(["cloudflared", "tunnel", "--url", `http://localhost:${port}`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
// cloudflared prints the URL to stderr
|
||||
const reader = proc.stderr.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const deadline = Date.now() + 30_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const match = buffer.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
||||
if (match) {
|
||||
// Release the reader so stderr keeps flowing without backpressure
|
||||
reader.releaseLock();
|
||||
return { process: proc, url: match[0] };
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
proc.kill();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerWithGateway(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${gatewayUrl}/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, url: tunnelUrl, secret, agentToken }),
|
||||
});
|
||||
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}/register/${name}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${secret}` },
|
||||
});
|
||||
} catch {
|
||||
// Best effort — process is exiting
|
||||
}
|
||||
}
|
||||
|
||||
export function startHeartbeat(
|
||||
gatewayUrl: string,
|
||||
name: string,
|
||||
tunnelUrl: string,
|
||||
secret: string,
|
||||
agentToken: string,
|
||||
intervalMs: number,
|
||||
): ReturnType<typeof setInterval> {
|
||||
return setInterval(() => {
|
||||
registerWithGateway(gatewayUrl, name, tunnelUrl, secret, agentToken).catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ServeOptions = {
|
||||
port: number;
|
||||
hostname: string;
|
||||
name: string;
|
||||
noTunnel: boolean;
|
||||
gatewayUrl: string;
|
||||
gatewaySecret: string;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import {
|
||||
readWorkerCtl,
|
||||
resolveRunningHashForThread,
|
||||
sendWorkerTcpCommand,
|
||||
} from "../../worker-spawn.js";
|
||||
|
||||
type ThreadControlAction = "kill" | "pause" | "resume";
|
||||
|
||||
async function cmdThreadControl(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
action: ThreadControlAction,
|
||||
): Promise<Result<void, string>> {
|
||||
const hashResult = await resolveRunningHashForThread(storageRoot, threadId);
|
||||
if (!hashResult.ok) {
|
||||
return hashResult;
|
||||
}
|
||||
|
||||
const ctlResult = await readWorkerCtl(storageRoot, hashResult.value);
|
||||
if (!ctlResult.ok) {
|
||||
return ctlResult;
|
||||
}
|
||||
|
||||
return await sendWorkerTcpCommand(
|
||||
ctlResult.value.port,
|
||||
{ type: action, threadId },
|
||||
{ awaitResponseLine: true },
|
||||
);
|
||||
}
|
||||
|
||||
export async function cmdKill(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "kill");
|
||||
}
|
||||
|
||||
export async function cmdPause(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "pause");
|
||||
}
|
||||
|
||||
export async function cmdResume(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
return cmdThreadControl(storageRoot, threadId, "resume");
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { parseLiveArgv } from "../../live-argv.js";
|
||||
import { parseRunArgv } from "../../run-argv.js";
|
||||
import { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||
import { cmdFork } from "./fork.js";
|
||||
import { parseForkArgv } from "./fork-argv.js";
|
||||
import { cmdThreads } from "./list.js";
|
||||
import { cmdLive } from "./live.js";
|
||||
import { cmdPs } from "./ps.js";
|
||||
import { cmdThreadRemove } from "./rm.js";
|
||||
import { cmdRun } from "./run.js";
|
||||
import { cmdThreadShow } from "./show.js";
|
||||
import type { ThreadDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchRun(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseRunArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await cmdRun(
|
||||
storageRoot,
|
||||
parsed.value.name,
|
||||
parsed.value.prompt,
|
||||
parsed.value.maxRounds,
|
||||
);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchPs(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: ps takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
for (const line of await cmdPs(storageRoot)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchKill(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: kill requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdKill(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`kill sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseLiveArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
return cmdLive(storageRoot, parsed.value);
|
||||
}
|
||||
|
||||
export async function dispatchPause(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: pause requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdPause(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`pause sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchResume(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: resume requires <thread-id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdResume(storageRoot, threadId);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`resume sent for thread ${threadId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = await cmdThreads(storageRoot, argv);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: thread show requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadShow(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const id = argv[0];
|
||||
if (id === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: thread rm requires <id>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdThreadRemove(storageRoot, id);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed thread ${id}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchFork(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseForkArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdFork(storageRoot, parsed.value.threadId, parsed.value.fromRole);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(result.value.threadId);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
run: {
|
||||
handler: dispatchRun,
|
||||
args: "<name> [--prompt <text>] [--max-rounds N]",
|
||||
description: "Start a new thread executing a workflow",
|
||||
},
|
||||
list: {
|
||||
handler: dispatchThreadList,
|
||||
args: "[name]",
|
||||
description: "List threads, optionally filtered by workflow name",
|
||||
},
|
||||
show: { handler: dispatchThreadShow, args: "<id>", description: "Show thread details and state" },
|
||||
rm: { handler: dispatchThreadRm, args: "<id>", description: "Remove a thread" },
|
||||
fork: {
|
||||
handler: dispatchFork,
|
||||
args: "<thread-id> [--from-role <role>]",
|
||||
description: "Fork a thread, optionally from a specific role",
|
||||
},
|
||||
ps: { handler: dispatchPs, args: "", description: "List running threads" },
|
||||
kill: { handler: dispatchKill, args: "<thread-id>", description: "Kill a running thread" },
|
||||
live: {
|
||||
handler: dispatchLive,
|
||||
args: "<thread-id> | --latest [--debug] [--role <name>]",
|
||||
description: "Attach to a thread and stream output live",
|
||||
},
|
||||
pause: { handler: dispatchPause, args: "<thread-id>", description: "Pause a running thread" },
|
||||
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
|
||||
};
|
||||
|
||||
export function createThreadDispatcher(deps: ThreadDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchThread(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("thread", THREAD_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
printCliError(`${usageText()}\n\nerror: unknown thread subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedForkArgv } from "./types.js";
|
||||
|
||||
export function parseForkArgv(argv: string[]): Result<ParsedForkArgv, string> {
|
||||
if (argv.length === 0) {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
const threadId = argv[0];
|
||||
if (threadId === undefined || threadId === "") {
|
||||
return err("fork requires <thread-id>");
|
||||
}
|
||||
let fromRole: string | null = null;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--from-role") {
|
||||
const r = argv[i + 1];
|
||||
if (r === undefined || r === "") {
|
||||
return err("--from-role requires a role name");
|
||||
}
|
||||
fromRole = r;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
return err(`unexpected argument: ${a}`);
|
||||
}
|
||||
return ok({ threadId, fromRole });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { join } from "node:path";
|
||||
import { createCasStore } from "@uncaged/workflow-cas";
|
||||
import { prepareCasFork } from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { generateUlid, getGlobalCasDir } from "@uncaged/workflow-util";
|
||||
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
|
||||
export async function cmdFork(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
fromRole: string | null,
|
||||
): Promise<Result<{ threadId: string }, string>> {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${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 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 worker = await ensureWorkerForHash(storageRoot, plan.value.hash, bundlePath);
|
||||
if (!worker.ok) {
|
||||
return worker;
|
||||
}
|
||||
|
||||
const p = plan.value;
|
||||
const sent = await sendWorkerTcpCommand(
|
||||
worker.value.port,
|
||||
{
|
||||
type: "run",
|
||||
threadId: newThreadId,
|
||||
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 },
|
||||
);
|
||||
if (!sent.ok) {
|
||||
return sent;
|
||||
}
|
||||
|
||||
return ok({ threadId: newThreadId });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export { cmdKill, cmdPause, cmdResume } from "./control.js";
|
||||
export {
|
||||
createThreadDispatcher,
|
||||
dispatchFork,
|
||||
dispatchKill,
|
||||
dispatchLive,
|
||||
dispatchPause,
|
||||
dispatchPs,
|
||||
dispatchResume,
|
||||
dispatchRun,
|
||||
dispatchThreadList,
|
||||
dispatchThreadRm,
|
||||
dispatchThreadShow,
|
||||
THREAD_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdFork } from "./fork.js";
|
||||
export { parseForkArgv } from "./fork-argv.js";
|
||||
export { cmdThreads } from "./list.js";
|
||||
export {
|
||||
cmdLive,
|
||||
formatLiveDebugLine,
|
||||
formatLiveTimeLabel,
|
||||
LIVE_CONTENT_MAX_LINES,
|
||||
renderLiveRoleStepLines,
|
||||
} from "./live.js";
|
||||
export { cmdPs } from "./ps.js";
|
||||
export { cmdThreadRemove } from "./rm.js";
|
||||
export { cmdRun } from "./run.js";
|
||||
export { cmdThreadShow } from "./show.js";
|
||||
export type { LiveRoleRow } from "./types.js";
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { listHistoricalThreads } from "./thread-scan.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { listHistoricalThreads } from "../../thread-scan.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdThreads(
|
||||
storageRoot: string,
|
||||
@@ -0,0 +1,502 @@
|
||||
import { watch } from "node:fs";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createCasStore, getContentMerklePayload } from "@uncaged/workflow-cas";
|
||||
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 {
|
||||
findLatestThreadBundleTarget,
|
||||
type LatestThreadTarget,
|
||||
resolveThreadRecord,
|
||||
} from "../../thread-scan.js";
|
||||
import type { LiveRoleRow } from "./types.js";
|
||||
|
||||
export const LIVE_CONTENT_MAX_LINES = 10;
|
||||
|
||||
export function formatLiveTimeLabel(timestampMs: number): string {
|
||||
const d = new Date(timestampMs);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
|
||||
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
|
||||
return dimGreyLine(label);
|
||||
}
|
||||
|
||||
export function renderLiveRoleStepLines(row: LiveRoleRow, roleDisplay: string): string[] {
|
||||
const header = `[${formatLiveTimeLabel(row.timestamp)}] ▶ ${roleDisplay}`;
|
||||
const lines: string[] = [header];
|
||||
const parts = row.content.split("\n");
|
||||
const shown = parts.slice(0, LIVE_CONTENT_MAX_LINES);
|
||||
for (const ln of shown) {
|
||||
lines.push(` ${ln}`);
|
||||
}
|
||||
const omitted = parts.length - shown.length;
|
||||
if (omitted > 0) {
|
||||
lines.push(` … (${omitted} more line${omitted === 1 ? "" : "s"})`);
|
||||
}
|
||||
lines.push(` meta: ${JSON.stringify(row.meta)}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function printSummary(result: WorkflowCompletion): void {
|
||||
printCliLine(`completed: returnCode=${result.returnCode} — ${result.summary}`);
|
||||
}
|
||||
|
||||
type InfoLiveState = {
|
||||
carry: string;
|
||||
contentOffset: number;
|
||||
};
|
||||
|
||||
type CasLiveState = {
|
||||
printedHashes: Set<string>;
|
||||
lastHead: string | null;
|
||||
completionEmitted: boolean;
|
||||
};
|
||||
|
||||
function tryParseInfoRecord(obj: Record<string, unknown>): {
|
||||
tag: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
} | null {
|
||||
const tag = obj.tag;
|
||||
const content = obj.content;
|
||||
const timestamp = obj.timestamp;
|
||||
if (
|
||||
typeof tag !== "string" ||
|
||||
typeof content !== "string" ||
|
||||
typeof timestamp !== "number" ||
|
||||
!Number.isFinite(timestamp)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { tag, content, timestamp };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const payload = await getContentMerklePayload(params.cas, params.contentHash);
|
||||
const content =
|
||||
payload !== null ? payload : `(content not in CAS; contentHash=${params.contentHash})`;
|
||||
|
||||
const row: LiveRoleRow = {
|
||||
role: params.role,
|
||||
content,
|
||||
meta: params.meta,
|
||||
timestamp: params.timestamp,
|
||||
};
|
||||
for (const outLine of renderLiveRoleStepLines(row, highlightLiveRole(row.role))) {
|
||||
printCliLine(outLine);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
idx = await readThreadsIndex(params.bundleDir);
|
||||
} catch {
|
||||
idx = {};
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function pumpNewInfoContent(infoPath: string, state: InfoLiveState): Promise<void> {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(infoPath, "utf8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (text.length < state.contentOffset) {
|
||||
state.contentOffset = 0;
|
||||
state.carry = "";
|
||||
}
|
||||
|
||||
const chunk = text.slice(state.contentOffset);
|
||||
state.contentOffset = text.length;
|
||||
state.carry += chunk;
|
||||
|
||||
const parts = state.carry.split("\n");
|
||||
state.carry = parts.pop() ?? "";
|
||||
|
||||
for (const line of parts) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (rec === null || typeof rec !== "object") {
|
||||
continue;
|
||||
}
|
||||
const parsed = tryParseInfoRecord(rec as Record<string, unknown>);
|
||||
if (parsed === null) {
|
||||
continue;
|
||||
}
|
||||
printCliLine(formatLiveDebugLine(parsed.timestamp, parsed.tag, parsed.content));
|
||||
}
|
||||
}
|
||||
|
||||
type WatchPumpTask = {
|
||||
path: string;
|
||||
pump: () => Promise<number | null>;
|
||||
};
|
||||
|
||||
async function runWatchPumpStep(
|
||||
settled: () => boolean,
|
||||
pump: () => Promise<number | null>,
|
||||
closeAll: () => void,
|
||||
finish: (code: number) => void,
|
||||
): Promise<void> {
|
||||
if (settled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const code = await pump();
|
||||
if (code !== null) {
|
||||
closeAll();
|
||||
finish(code);
|
||||
}
|
||||
} catch (e) {
|
||||
closeAll();
|
||||
throw e instanceof Error ? e : new Error(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
function watchLivePaths(params: { tasks: WatchPumpTask[]; signal: AbortSignal }): Promise<number> {
|
||||
const { tasks, signal } = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (code: number): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const pumpChains = new Map<string, Promise<void>>();
|
||||
for (const t of tasks) {
|
||||
pumpChains.set(t.path, Promise.resolve());
|
||||
}
|
||||
|
||||
const watchers: ReturnType<typeof watch>[] = [];
|
||||
|
||||
const closeAll = (): void => {
|
||||
for (const w of watchers) {
|
||||
w.close();
|
||||
}
|
||||
};
|
||||
|
||||
function schedulePump(path: string, pump: () => Promise<number | null>): void {
|
||||
const prev = pumpChains.get(path) ?? Promise.resolve();
|
||||
const next = (async () => {
|
||||
await prev;
|
||||
await runWatchPumpStep(() => settled, pump, closeAll, finish);
|
||||
})();
|
||||
pumpChains.set(path, next);
|
||||
}
|
||||
|
||||
for (const { path, pump } of tasks) {
|
||||
const watcher = watch(path, (eventType) => {
|
||||
if (eventType === "rename") {
|
||||
return;
|
||||
}
|
||||
schedulePump(path, pump);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
watcher.on("error", (errObj: Error) => {
|
||||
closeAll();
|
||||
reject(errObj);
|
||||
});
|
||||
}
|
||||
|
||||
const onAbort = (): void => {
|
||||
closeAll();
|
||||
finish(0);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
for (const { path, pump } of tasks) {
|
||||
schedulePump(path, pump);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
type LiveThreadTarget = LatestThreadTarget;
|
||||
|
||||
async function resolveLiveThreadTarget(
|
||||
storageRoot: string,
|
||||
parsed: ParsedLiveArgv,
|
||||
): Promise<LiveThreadTarget | null> {
|
||||
if (parsed.latest) {
|
||||
const found = await findLatestThreadBundleTarget(storageRoot);
|
||||
if (found === null) {
|
||||
printCliError("live: no threads found");
|
||||
return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const id = parsed.threadId;
|
||||
if (id === null) {
|
||||
printCliError("live: internal error: missing thread id");
|
||||
return null;
|
||||
}
|
||||
const resolved = await resolveThreadRecord(storageRoot, id);
|
||||
if (resolved === null) {
|
||||
printCliError(`thread not found: ${id}`);
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
threadId: id,
|
||||
bundleHash: resolved.bundleHash,
|
||||
bundleDir: resolved.bundleDir,
|
||||
threadsJsonPath: join(resolved.bundleDir, "threads.json"),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildLiveWatchTasks(params: {
|
||||
storageRoot: string;
|
||||
target: LiveThreadTarget;
|
||||
debug: boolean;
|
||||
dataState: CasLiveState;
|
||||
infoState: InfoLiveState;
|
||||
roleFilter: string | null;
|
||||
cas: CasStore;
|
||||
}): Promise<WatchPumpTask[]> {
|
||||
const infoPath = join(
|
||||
params.storageRoot,
|
||||
"logs",
|
||||
params.target.bundleHash,
|
||||
`${params.target.threadId}.info.jsonl`,
|
||||
);
|
||||
|
||||
const tasks: WatchPumpTask[] = [
|
||||
{
|
||||
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 (params.debug && (await pathExists(infoPath))) {
|
||||
tasks.push({
|
||||
path: infoPath,
|
||||
pump: async () => {
|
||||
await pumpNewInfoContent(infoPath, params.infoState);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
export async function cmdLive(storageRoot: string, parsed: ParsedLiveArgv): Promise<number> {
|
||||
const target = await resolveLiveThreadTarget(storageRoot, parsed);
|
||||
if (target === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const roleFilter = parsed.role;
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const dataState: CasLiveState = {
|
||||
printedHashes: new Set<string>(),
|
||||
lastHead: null,
|
||||
completionEmitted: false,
|
||||
};
|
||||
|
||||
const infoState: InfoLiveState = {
|
||||
carry: "",
|
||||
contentOffset: 0,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const onSigInt = (): void => {
|
||||
controller.abort();
|
||||
};
|
||||
process.on("SIGINT", onSigInt);
|
||||
|
||||
try {
|
||||
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) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tasks = await buildLiveWatchTasks({
|
||||
storageRoot,
|
||||
target,
|
||||
debug: parsed.debug,
|
||||
dataState,
|
||||
infoState,
|
||||
roleFilter,
|
||||
cas,
|
||||
});
|
||||
|
||||
return await watchLivePaths({ tasks, signal: controller.signal });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
printCliError(`live: ${message}`);
|
||||
return 1;
|
||||
} finally {
|
||||
process.off("SIGINT", onSigInt);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { listRunningThreads } from "./thread-scan.js";
|
||||
import { listRunningThreads } from "../../thread-scan.js";
|
||||
|
||||
export async function cmdPs(storageRoot: string): Promise<string[]> {
|
||||
const rows = await listRunningThreads(storageRoot);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
garbageCollectCas,
|
||||
removeThreadEntry,
|
||||
removeThreadHistoryEntries,
|
||||
} from "@uncaged/workflow-execute";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
export async function cmdThreadRemove(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<void, string>> {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
if (resolved.source === "active") {
|
||||
await removeThreadEntry(resolved.bundleDir, threadId);
|
||||
} else {
|
||||
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(infoPath).catch(() => {});
|
||||
await unlink(runningPath).catch(() => {});
|
||||
|
||||
await garbageCollectCas(storageRoot);
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
+5
-10
@@ -1,15 +1,10 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
err,
|
||||
generateUlid,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "./worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
import { generateUlid } from "@uncaged/workflow-util";
|
||||
import { ensureWorkerForHash, sendWorkerTcpCommand } from "../../worker-spawn.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdRun(
|
||||
storageRoot: string,
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCasStore, getContentMerklePayload } 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 { resolveThreadRecord } from "../../thread-scan.js";
|
||||
|
||||
export async function cmdThreadShow(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<Result<string, string>> {
|
||||
const resolved = await resolveThreadRecord(storageRoot, threadId);
|
||||
if (resolved === null) {
|
||||
return err(`thread not found: ${threadId}`);
|
||||
}
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const frames = await walkStateFramesNewestFirst(cas, resolved.head);
|
||||
const chronological = [...frames].reverse();
|
||||
|
||||
const steps: Array<{ role: string; hash: string; timestamp: number; content: string }> = [];
|
||||
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})`,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
threadId: resolved.threadId,
|
||||
bundleHash: resolved.bundleHash,
|
||||
head: resolved.head,
|
||||
start: resolved.start,
|
||||
source: resolved.source,
|
||||
steps,
|
||||
};
|
||||
|
||||
return ok(JSON.stringify(payload, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type LiveRoleRow = {
|
||||
role: string;
|
||||
content: string;
|
||||
meta: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ParsedForkArgv = {
|
||||
threadId: string;
|
||||
fromRole: string | null;
|
||||
};
|
||||
|
||||
export type ThreadDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
import type { ParsedAddArgv } from "./types.js";
|
||||
|
||||
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
|
||||
|
||||
function tryParseAddLongFlag(argv: string[], index: number): Result<ParsedLongFlag | null, string> {
|
||||
const tok = argv[index];
|
||||
if (tok !== "--types") {
|
||||
return ok(null);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
return err("missing value for --types");
|
||||
}
|
||||
return ok({ advance: 2, kind: "types", value });
|
||||
}
|
||||
|
||||
type PositionalSlots = {
|
||||
name: string | undefined;
|
||||
filePath: string | undefined;
|
||||
};
|
||||
|
||||
function assignPositional(tok: string, slots: PositionalSlots): Result<void, string> {
|
||||
if (slots.name === undefined) {
|
||||
slots.name = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
if (slots.filePath === undefined) {
|
||||
slots.filePath = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
return err("too many arguments");
|
||||
}
|
||||
|
||||
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
|
||||
const slots: PositionalSlots = { name: undefined, filePath: undefined };
|
||||
let typesPath: string | null = null;
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const flag = tryParseAddLongFlag(argv, i);
|
||||
if (!flag.ok) {
|
||||
return flag;
|
||||
}
|
||||
if (flag.value !== null) {
|
||||
typesPath = flag.value.value;
|
||||
i += flag.value.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tok = argv[i];
|
||||
if (tok?.startsWith("--")) {
|
||||
return err(`unknown add flag: ${tok}`);
|
||||
}
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
const placed = assignPositional(tok, slots);
|
||||
if (!placed.ok) {
|
||||
return placed;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const { name, filePath } = slots;
|
||||
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
|
||||
return err("add requires <name> <file>");
|
||||
}
|
||||
|
||||
return ok({ name, filePath, typesPath });
|
||||
}
|
||||
+7
-89
@@ -1,33 +1,20 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
|
||||
import { hashWorkflowBundleBytes } from "@uncaged/workflow-cas";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
extractBundleExports,
|
||||
hashWorkflowBundleBytes,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
stringifyWorkflowDescriptor,
|
||||
validateWorkflowBundle,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
import { storeWorkflowBundleArtifacts } from "./bundle-store.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export type ParsedAddArgv = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
/** Override path to `.d.ts` when adding a bundle. */
|
||||
typesPath: string | null;
|
||||
};
|
||||
|
||||
export type CmdAddSuccess = {
|
||||
hash: string;
|
||||
warnings: ReadonlyArray<string>;
|
||||
};
|
||||
import type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
|
||||
|
||||
function isEsmBundle(path: string): boolean {
|
||||
return path.endsWith(".esm.js");
|
||||
@@ -37,75 +24,6 @@ function defaultTypesPath(bundlePath: string): string {
|
||||
return bundlePath.replace(/\.esm\.js$/i, ".d.ts");
|
||||
}
|
||||
|
||||
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
|
||||
|
||||
function tryParseAddLongFlag(argv: string[], index: number): Result<ParsedLongFlag | null, string> {
|
||||
const tok = argv[index];
|
||||
if (tok !== "--types") {
|
||||
return ok(null);
|
||||
}
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
return err("missing value for --types");
|
||||
}
|
||||
return ok({ advance: 2, kind: "types", value });
|
||||
}
|
||||
|
||||
type PositionalSlots = {
|
||||
name: string | undefined;
|
||||
filePath: string | undefined;
|
||||
};
|
||||
|
||||
function assignPositional(tok: string, slots: PositionalSlots): Result<void, string> {
|
||||
if (slots.name === undefined) {
|
||||
slots.name = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
if (slots.filePath === undefined) {
|
||||
slots.filePath = tok;
|
||||
return ok(undefined);
|
||||
}
|
||||
return err("too many arguments");
|
||||
}
|
||||
|
||||
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
|
||||
const slots: PositionalSlots = { name: undefined, filePath: undefined };
|
||||
let typesPath: string | null = null;
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const flag = tryParseAddLongFlag(argv, i);
|
||||
if (!flag.ok) {
|
||||
return flag;
|
||||
}
|
||||
if (flag.value !== null) {
|
||||
typesPath = flag.value.value;
|
||||
i += flag.value.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tok = argv[i];
|
||||
if (tok?.startsWith("--")) {
|
||||
return err(`unknown add flag: ${tok}`);
|
||||
}
|
||||
if (tok === undefined) {
|
||||
break;
|
||||
}
|
||||
const placed = assignPositional(tok, slots);
|
||||
if (!placed.ok) {
|
||||
return placed;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const { name, filePath } = slots;
|
||||
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
|
||||
return err("add requires <name> <file>");
|
||||
}
|
||||
|
||||
return ok({ name, filePath, typesPath });
|
||||
}
|
||||
|
||||
async function registerHash(
|
||||
storageRoot: string,
|
||||
name: string,
|
||||
@@ -192,7 +110,7 @@ export async function cmdAdd(
|
||||
return validated;
|
||||
}
|
||||
|
||||
const extracted = await extractBundleExports(resolvedPath);
|
||||
const extracted = await extractBundleExports(resolvedPath, { storageRoot });
|
||||
if (!extracted.ok) {
|
||||
return extracted;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { CommandEntry } from "../../cli-command-types.js";
|
||||
import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js";
|
||||
import { formatCliUsage, USAGE_SKILL_TOPIC_ROWS } from "../../cli-usage.js";
|
||||
import { getCommandGroupsForUsage } from "../../cli-usage-context.js";
|
||||
import { cmdAdd, formatAddSuccess } from "./add.js";
|
||||
import { parseAddArgv } from "./add-argv.js";
|
||||
import { cmdHistory } from "./history.js";
|
||||
import { cmdList, formatListLines } from "./list.js";
|
||||
import { cmdRemove } from "./rm.js";
|
||||
import { cmdRollback } from "./rollback.js";
|
||||
import { cmdShow, formatShowYaml } from "./show.js";
|
||||
import type { WorkflowDispatchDeps } from "./types.js";
|
||||
|
||||
function usageText(): string {
|
||||
return formatCliUsage(getCommandGroupsForUsage(), USAGE_SKILL_TOPIC_ROWS);
|
||||
}
|
||||
|
||||
export async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const parsed = parseAddArgv(argv);
|
||||
if (!parsed.ok) {
|
||||
printCliError(`${usageText()}\n\nerror: ${parsed.error}`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdAdd(storageRoot, parsed.value);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const w of result.value.warnings) {
|
||||
printCliWarn(w);
|
||||
}
|
||||
printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchList(storageRoot: string, argv: string[]): Promise<number> {
|
||||
if (argv.length > 0) {
|
||||
printCliError(`${usageText()}\n\nerror: list takes no arguments`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdList(storageRoot);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of formatListLines(result.value)) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchShow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: show requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdShow(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(formatShowYaml(name, result.value));
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchRemove(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: remove requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdRemove(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`removed workflow "${name}" from registry`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 1) {
|
||||
printCliError(`${usageText()}\n\nerror: history requires <name>`);
|
||||
return 1;
|
||||
}
|
||||
const result = await cmdHistory(storageRoot, name);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
for (const line of result.value) {
|
||||
printCliLine(line);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function dispatchRollback(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const name = argv[0];
|
||||
if (name === undefined || argv.length > 2) {
|
||||
printCliError(`${usageText()}\n\nerror: rollback requires <name> [hash]`);
|
||||
return 1;
|
||||
}
|
||||
const hashArg = argv[1];
|
||||
const result = await cmdRollback(storageRoot, name, hashArg === undefined ? null : hashArg);
|
||||
if (!result.ok) {
|
||||
printCliError(result.error);
|
||||
return 1;
|
||||
}
|
||||
printCliLine(`rolled back workflow "${name}"`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
||||
add: {
|
||||
handler: dispatchAdd,
|
||||
args: "<name> <file.esm.js> [--types <path>]",
|
||||
description: "Register a workflow bundle in the registry",
|
||||
},
|
||||
list: { handler: dispatchList, args: "", description: "List all registered workflows" },
|
||||
show: {
|
||||
handler: dispatchShow,
|
||||
args: "<name>",
|
||||
description: "Show details of a registered workflow",
|
||||
},
|
||||
rm: {
|
||||
handler: dispatchRemove,
|
||||
args: "<name>",
|
||||
description: "Remove a workflow from the registry",
|
||||
},
|
||||
history: {
|
||||
handler: dispatchHistory,
|
||||
args: "<name>",
|
||||
description: "Show version history of a workflow",
|
||||
},
|
||||
rollback: {
|
||||
handler: dispatchRollback,
|
||||
args: "<name> [hash]",
|
||||
description: "Rollback a workflow to a previous version",
|
||||
},
|
||||
};
|
||||
|
||||
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
||||
const { dispatchGroup } = deps;
|
||||
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
const sub = argv[0];
|
||||
if (sub === "remove") {
|
||||
return dispatchRemove(storageRoot, argv.slice(1));
|
||||
}
|
||||
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
+3
-8
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow-register";
|
||||
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdHistory(
|
||||
storageRoot: string,
|
||||
@@ -0,0 +1,18 @@
|
||||
export { cmdAdd, formatAddSuccess } from "./add.js";
|
||||
export { parseAddArgv } from "./add-argv.js";
|
||||
export {
|
||||
createWorkflowDispatcher,
|
||||
dispatchAdd,
|
||||
dispatchHistory,
|
||||
dispatchList,
|
||||
dispatchRemove,
|
||||
dispatchRollback,
|
||||
dispatchShow,
|
||||
WORKFLOW_SUBCOMMAND_TABLE,
|
||||
} from "./dispatch.js";
|
||||
export { cmdHistory } from "./history.js";
|
||||
export { cmdList, formatListLines } from "./list.js";
|
||||
export { cmdRemove } from "./rm.js";
|
||||
export { cmdRollback } from "./rollback.js";
|
||||
export { cmdShow, formatShowYaml } from "./show.js";
|
||||
export type { CmdAddSuccess, ParsedAddArgv } from "./types.js";
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
listRegisteredWorkflowNames,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
type WorkflowRegistryFile,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
export async function cmdList(storageRoot: string): Promise<Result<WorkflowRegistryFile, string>> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
+3
-5
@@ -1,13 +1,11 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
unregisterWorkflow,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdRemove(storageRoot: string, name: string): Promise<Result<void, string>> {
|
||||
const nameOk = validateCliWorkflowName(name);
|
||||
+5
-6
@@ -1,17 +1,15 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
writeWorkflowRegistry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
|
||||
import { pathExists } from "./fs-utils.js";
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { pathExists } from "../../fs-utils.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdRollback(
|
||||
storageRoot: string,
|
||||
@@ -44,6 +42,7 @@ export async function cmdRollback(
|
||||
}
|
||||
|
||||
const nextRegistry = {
|
||||
config: reg.value.config,
|
||||
workflows: { ...reg.value.workflows, [name]: rolled.value },
|
||||
};
|
||||
const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
|
||||
+3
-5
@@ -1,14 +1,12 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
import {
|
||||
err,
|
||||
getRegisteredWorkflow,
|
||||
ok,
|
||||
type Result,
|
||||
readWorkflowRegistry,
|
||||
type WorkflowRegistryEntry,
|
||||
} from "@uncaged/workflow";
|
||||
} from "@uncaged/workflow-register";
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import { validateCliWorkflowName } from "./workflow-name.js";
|
||||
import { validateCliWorkflowName } from "../../workflow-name.js";
|
||||
|
||||
export async function cmdShow(
|
||||
storageRoot: string,
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { DispatchGroupFn } from "../../cli-command-types.js";
|
||||
|
||||
export type ParsedAddArgv = {
|
||||
name: string;
|
||||
filePath: string;
|
||||
/** Override path to `.d.ts` when adding a bundle. */
|
||||
typesPath: string | null;
|
||||
};
|
||||
|
||||
export type CmdAddSuccess = {
|
||||
hash: string;
|
||||
warnings: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
export type WorkflowDispatchDeps = {
|
||||
dispatchGroup: DispatchGroupFn;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type ParsedLiveArgv = {
|
||||
threadId: string | null;
|
||||
latest: boolean;
|
||||
debug: boolean;
|
||||
role: string | null;
|
||||
};
|
||||
|
||||
type LiveArgvScan = {
|
||||
latest: boolean;
|
||||
debug: boolean;
|
||||
role: string | null;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function applyLiveArgvToken(argv: string[], i: number, s: LiveArgvScan): Result<number, string> {
|
||||
const a = argv[i];
|
||||
if (a === "--latest") {
|
||||
s.latest = true;
|
||||
return ok(i + 1);
|
||||
}
|
||||
if (a === "--debug") {
|
||||
s.debug = true;
|
||||
return ok(i + 1);
|
||||
}
|
||||
if (a === "--role") {
|
||||
const v = argv[i + 1];
|
||||
if (v === undefined || v.startsWith("--")) {
|
||||
return err("missing value for --role");
|
||||
}
|
||||
s.role = v;
|
||||
return ok(i + 2);
|
||||
}
|
||||
if (a.startsWith("--")) {
|
||||
return err(`unknown live flag: ${a}`);
|
||||
}
|
||||
if (s.threadId !== null) {
|
||||
return err("unexpected extra argument");
|
||||
}
|
||||
s.threadId = a;
|
||||
return ok(i + 1);
|
||||
}
|
||||
|
||||
export function parseLiveArgv(argv: string[]): Result<ParsedLiveArgv, string> {
|
||||
const s: LiveArgvScan = {
|
||||
latest: false,
|
||||
debug: false,
|
||||
role: null,
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < argv.length) {
|
||||
const step = applyLiveArgvToken(argv, i, s);
|
||||
if (!step.ok) {
|
||||
return step;
|
||||
}
|
||||
i = step.value;
|
||||
}
|
||||
|
||||
if (s.latest && s.threadId !== null) {
|
||||
return err("live --latest does not take <thread-id>");
|
||||
}
|
||||
if (!s.latest && s.threadId === null) {
|
||||
return err("live requires <thread-id> or --latest");
|
||||
}
|
||||
|
||||
return ok({
|
||||
threadId: s.threadId,
|
||||
latest: s.latest,
|
||||
debug: s.debug,
|
||||
role: s.role,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
export type ParsedRunArgv = {
|
||||
name: string;
|
||||
@@ -34,7 +34,7 @@ function parseFlagAt(argv: string[], index: number): Result<FlagOk, string> | nu
|
||||
export function parseRunArgv(argv: string[]): Result<ParsedRunArgv, string> {
|
||||
let name: string | undefined;
|
||||
let prompt = "";
|
||||
let maxRounds = 5;
|
||||
let maxRounds = 10;
|
||||
|
||||
let i = 0;
|
||||
const first = argv[0];
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { getCommandRegistry } from "./cli-registry.js";
|
||||
|
||||
type SkillTopic = {
|
||||
name: string;
|
||||
description: string;
|
||||
format: () => string;
|
||||
};
|
||||
|
||||
const SKILL_TOPICS: ReadonlyArray<SkillTopic> = [
|
||||
{ name: "cli", description: "Full CLI command reference", format: formatSkillCli },
|
||||
{
|
||||
name: "develop",
|
||||
description: "Guide for agents executing roles inside a workflow",
|
||||
format: formatSkillDevelop,
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
description: "Guide for building and publishing workflow bundles",
|
||||
format: formatSkillAuthor,
|
||||
},
|
||||
];
|
||||
|
||||
export function getSkillTopics(): ReadonlyArray<{ name: string; description: string }> {
|
||||
return SKILL_TOPICS.map((t) => ({ name: t.name, description: t.description }));
|
||||
}
|
||||
|
||||
export function formatSkillTopic(topic: string): string | null {
|
||||
const entry = SKILL_TOPICS.find((t) => t.name === topic);
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
return entry.format();
|
||||
}
|
||||
|
||||
export function formatSkillIndex(): string {
|
||||
const rows = SKILL_TOPICS.map((t) => `| \`${t.name}\` | ${t.description} |`);
|
||||
return `# uncaged-workflow skill
|
||||
|
||||
Available topics:
|
||||
|
||||
| Topic | Description |
|
||||
|-------|-------------|
|
||||
${rows.join("\n")}
|
||||
|
||||
Usage: \`uncaged-workflow skill <topic>\`
|
||||
`;
|
||||
}
|
||||
|
||||
// ── cli topic (existing full reference) ────────────────────────────────
|
||||
|
||||
function formatSkillCli(): string {
|
||||
const groups = getCommandRegistry();
|
||||
|
||||
const commandSections: string[] = [];
|
||||
for (const group of groups) {
|
||||
const rows = group.commands.map((cmd) => {
|
||||
const args = cmd.args ? `\`${cmd.args}\`` : "(none)";
|
||||
return `| \`${group.name} ${cmd.name}\` | ${args} | ${cmd.description} |`;
|
||||
});
|
||||
commandSections.push(
|
||||
`### ${group.name}\n\n| Command | Args | Description |\n|---------|------|-------------|\n${rows.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `# uncaged-workflow CLI Reference
|
||||
|
||||
## Core Concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **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. 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
|
||||
|
||||
${commandSections.join("\n\n")}
|
||||
|
||||
### Top-level shortcuts
|
||||
|
||||
| Command | Equivalent | Description |
|
||||
|---------|------------|-------------|
|
||||
| \`run\` | \`thread run\` | Shortcut to start a thread |
|
||||
| \`live\` | \`thread live\` | Shortcut to attach to a thread |
|
||||
|
||||
### serve
|
||||
|
||||
| Command | Args | Description |
|
||||
|---------|------|-------------|
|
||||
| \`serve\` | \`[--port N] [--host ADDR] [--name NAME]\` | Start HTTP API server with auto-tunnel. \`--name\` registers with the gateway. |
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. \`uncaged-workflow workflow add my-wf ./my-wf.esm.js\` — register a workflow
|
||||
2. \`uncaged-workflow run my-wf --prompt "do the thing"\` — start a thread
|
||||
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) |
|
||||
|
||||
## Defaults
|
||||
|
||||
| Setting | CLI | HTTP API |
|
||||
|---------|-----|----------|
|
||||
| \`maxRounds\` | 10 | 10 |
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | Error |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| \`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 |
|
||||
`;
|
||||
}
|
||||
|
||||
// ── develop topic (for agents inside a workflow) ───────────────────────
|
||||
|
||||
function formatSkillDevelop(): string {
|
||||
return `# Workflow Role Guide
|
||||
|
||||
Reference for agents executing roles (planner, coder, reviewer, etc.) inside a running workflow thread.
|
||||
|
||||
## Thread ID
|
||||
|
||||
Every thread has a 26-character Crockford Base32 ULID (e.g. \`06F03H5V6JTMDST6P3TVH42RWM\`).
|
||||
|
||||
It appears in the **first message** of the conversation. If unsure:
|
||||
|
||||
\`\`\`
|
||||
uncaged-workflow thread list
|
||||
\`\`\`
|
||||
|
||||
## CAS (Content-Addressable Storage)
|
||||
|
||||
Store and retrieve content by hash in workflow storage (global CAS directory).
|
||||
|
||||
| Operation | Command |
|
||||
|-----------|---------|
|
||||
| **Store** | \`uncaged-workflow cas put '<content>'\` → prints hash |
|
||||
| **Read** | \`uncaged-workflow cas get <HASH>\` → prints content |
|
||||
| **List** | \`uncaged-workflow cas list\` |
|
||||
|
||||
CAS is the **only** supported way to persist structured data (phase plans, review notes, etc.) within a thread. Do not use temp files.
|
||||
|
||||
## Meta Output
|
||||
|
||||
Each role must produce structured output that the moderator extracts. The exact schema depends on the role, but the pattern is:
|
||||
|
||||
1. Do your work (write code, run tests, etc.)
|
||||
2. Output a compact JSON object matching the role's schema
|
||||
3. The moderator extracts and validates it automatically
|
||||
|
||||
## Thread Context
|
||||
|
||||
The conversation history contains outputs from previous roles. Read it to understand:
|
||||
- What task was requested (from the initial prompt)
|
||||
- What previous roles produced (plans, code changes, review results)
|
||||
- What the moderator decided (which phase to work on, whether to retry)
|
||||
`;
|
||||
}
|
||||
|
||||
// ── author topic (for workflow developers) ─────────────────────────────
|
||||
|
||||
function formatSkillAuthor(): string {
|
||||
return `# Workflow Authoring Guide
|
||||
|
||||
How to build, test, and publish workflow bundles for uncaged-workflow.
|
||||
|
||||
## Bundle Structure
|
||||
|
||||
A workflow bundle is a single ESM file (\`.esm.js\`) that exports:
|
||||
|
||||
\`\`\`typescript
|
||||
// Required exports
|
||||
export const descriptor: WorkflowDescriptor;
|
||||
export const run: WorkflowRun;
|
||||
\`\`\`
|
||||
|
||||
## WorkflowDescriptor
|
||||
|
||||
Defines the workflow's metadata and role sequence:
|
||||
|
||||
\`\`\`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"]
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## WorkflowRun
|
||||
|
||||
The main function that creates and returns a moderator:
|
||||
|
||||
\`\`\`typescript
|
||||
type WorkflowRun = (ctx: WorkflowContext) => Moderator;
|
||||
\`\`\`
|
||||
|
||||
The **Moderator** controls the flow — it decides which role runs next, handles retries, and determines when the workflow is complete.
|
||||
|
||||
## Role Definition
|
||||
|
||||
Each role has:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| \`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 |
|
||||
|
||||
## Development Workflow
|
||||
|
||||
\`\`\`bash
|
||||
# 1. Initialize a workspace
|
||||
uncaged-workflow init workspace my-workflow
|
||||
|
||||
# 2. Write your template (roles + moderator + descriptor)
|
||||
|
||||
# 3. Build the ESM bundle
|
||||
bun run build
|
||||
|
||||
# 4. Register locally
|
||||
uncaged-workflow workflow add my-workflow ./dist/my-workflow.esm.js
|
||||
|
||||
# 5. Test
|
||||
uncaged-workflow run my-workflow --prompt "test task"
|
||||
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.
|
||||
`;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow";
|
||||
import { getDefaultWorkflowStorageRoot } from "@uncaged/workflow-util";
|
||||
|
||||
/** Resolve storage root, honoring `UNCAGED_WORKFLOW_STORAGE_ROOT` for tests/tools. */
|
||||
/**
|
||||
* Resolve storage root with env var override support.
|
||||
*
|
||||
* Priority (highest first):
|
||||
* 1. `UNCAGED_WORKFLOW_STORAGE_ROOT` — internal/test override
|
||||
* 2. `WORKFLOW_STORAGE_ROOT` — user-facing override
|
||||
* 3. Default (`~/.uncaged/workflow`)
|
||||
*/
|
||||
export function resolveWorkflowStorageRoot(): string {
|
||||
const override = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (override !== undefined && override !== "") {
|
||||
return override;
|
||||
const internal = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
|
||||
if (internal !== undefined && internal !== "") {
|
||||
return internal;
|
||||
}
|
||||
const userOverride = process.env.WORKFLOW_STORAGE_ROOT;
|
||||
if (userOverride !== undefined && userOverride !== "") {
|
||||
return userOverride;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,91 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
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;
|
||||
}
|
||||
const parsed = parseCasThreadNode(yamlText);
|
||||
if (parsed === null || parsed.kind !== "start") {
|
||||
return null;
|
||||
}
|
||||
return parsed.node.payload.name;
|
||||
}
|
||||
|
||||
async function listBundleHashDirs(storageRoot: string): Promise<string[]> {
|
||||
const bundlesRoot = join(storageRoot, "bundles");
|
||||
if (!(await pathExists(bundlesRoot))) {
|
||||
return [];
|
||||
}
|
||||
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 = {
|
||||
threadId: string;
|
||||
@@ -13,28 +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 readWorkflowNameFromDataJsonl(dataPath: string): Promise<string | 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 firstLine = text.split("\n")[0];
|
||||
if (firstLine === undefined || firstLine.trim() === "") {
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
if (newest.payload.role !== END) {
|
||||
return { kind: "non-terminal" };
|
||||
}
|
||||
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";
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(firstLine) as unknown;
|
||||
process.kill(ctlResult.value.pid, 0);
|
||||
} catch {
|
||||
return null;
|
||||
// Worker PID is dead, thread never finished — crashed
|
||||
return "failed";
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
const name = (parsed as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name : null;
|
||||
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. */
|
||||
@@ -61,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,20 +394,93 @@ export async function listHistoricalThreads(
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function resolveThreadDataPath(
|
||||
export type LatestThreadTarget = {
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
bundleDir: string;
|
||||
threadsJsonPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks the newest thread by StartNode timestamp approximation (`updatedAt` active,
|
||||
* else `completedAt` history), falling back to lexical thread id order.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: compares active heads vs history tails
|
||||
export async function findLatestThreadBundleTarget(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
): Promise<string | null> {
|
||||
const logsRoot = join(storageRoot, "logs");
|
||||
if (!(await pathExists(logsRoot))) {
|
||||
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;
|
||||
): Promise<LatestThreadTarget | null> {
|
||||
const hashes = await listBundleHashDirs(storageRoot);
|
||||
|
||||
let best: {
|
||||
threadId: string;
|
||||
bundleHash: string;
|
||||
bundleDir: string;
|
||||
ts: number;
|
||||
} | null = null;
|
||||
|
||||
for (const bundleHash of hashes) {
|
||||
const bundleDir = join(storageRoot, "bundles", bundleHash);
|
||||
let index: ThreadIndex;
|
||||
try {
|
||||
index = await readThreadsIndex(bundleDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
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 null;
|
||||
|
||||
if (best === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
threadId: best.threadId,
|
||||
bundleHash: best.bundleHash,
|
||||
bundleDir: best.bundleDir,
|
||||
threadsJsonPath: join(best.bundleDir, "threads.json"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { err, getWorkerHostScriptPath, ok, type Result } from "@uncaged/workflow";
|
||||
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;
|
||||
@@ -237,6 +238,30 @@ export async function sendWorkerTcpCommand(
|
||||
});
|
||||
}
|
||||
|
||||
export async function readWorkerCtl(
|
||||
storageRoot: string,
|
||||
hash: string,
|
||||
): Promise<Result<WorkerCtl, string>> {
|
||||
const ctlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||
const ctlText = await readTextFileIfExists(ctlPath);
|
||||
if (ctlText === null) {
|
||||
return err(`worker control file missing for bundle hash ${hash}`);
|
||||
}
|
||||
|
||||
let ctl: WorkerCtl;
|
||||
try {
|
||||
ctl = JSON.parse(ctlText) as WorkerCtl;
|
||||
} catch {
|
||||
return err(`corrupt worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
if (typeof ctl.port !== "number" || ctl.port <= 0) {
|
||||
return err(`invalid worker control file: ${ctlPath}`);
|
||||
}
|
||||
|
||||
return ok(ctl);
|
||||
}
|
||||
|
||||
export async function resolveRunningHashForThread(
|
||||
storageRoot: string,
|
||||
threadId: string,
|
||||
@@ -245,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)) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { err, ok, type Result } from "@uncaged/workflow";
|
||||
import { err, ok, type Result } from "@uncaged/workflow-protocol";
|
||||
|
||||
const WORKFLOW_NAME_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [{ "path": "../workflow" }],
|
||||
"references": [
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-execute" },
|
||||
{ "path": "../workflow-register" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# @uncaged/workflow-agent-cursor
|
||||
|
||||
`AgentFn` adapter that runs the `cursor-agent` CLI against a workspace path derived from the thread.
|
||||
|
||||
The agent builds a full prompt (system + task + step history via `@uncaged/workflow-util-agent`), extracts the absolute workspace path with your `extract` + Zod schema, then spawns `cursor-agent` with `--workspace`, model, and non-interactive flags.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow-agent-cursor @uncaged/workflow-runtime @uncaged/workflow-util-agent zod
|
||||
```
|
||||
|
||||
In this monorepo: `"@uncaged/workflow-agent-cursor": "workspace:*"` plus `workspace:*` for `@uncaged/workflow-runtime` and `@uncaged/workflow-util-agent`, and `zod` ^4.
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
|
||||
const agent = createCursorAgent({
|
||||
model: null, // null → "auto"
|
||||
timeout: 0, // ms; 0 = no limit (spawnCli timeout disabled)
|
||||
extract: myExtractFn,
|
||||
});
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `createCursorAgent(config)` | Returns `AgentFn` that runs `cursor-agent` with `buildAgentPrompt(ctx)` from `@uncaged/workflow-util-agent` |
|
||||
| `CursorAgentConfig` | `model`, `timeout`, `extract` (must supply workspace path) |
|
||||
| `validateCursorAgentConfig` | Config validation result |
|
||||
|
||||
Requires `cursor-agent` on `PATH` at runtime.
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||
|
||||
@@ -7,7 +7,11 @@ const testExtract: ExtractFn = async <T extends Record<string, unknown>>(
|
||||
_schema: z.ZodType<T>,
|
||||
_prompt: string,
|
||||
_ctx: ExtractContext,
|
||||
): Promise<T> => ({ workspace: "/tmp" }) as unknown as T;
|
||||
): Promise<{ meta: T; contentPayload: string; refs: string[] }> => ({
|
||||
meta: { workspace: "/tmp" } as unknown as T,
|
||||
contentPayload: "",
|
||||
refs: [],
|
||||
});
|
||||
|
||||
describe("validateCursorAgentConfig", () => {
|
||||
test("accepts valid config", () => {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
{
|
||||
"name": "@uncaged/workflow-agent-cursor",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "echo 'TODO'",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"@uncaged/workflow-util-agent": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user