Compare commits

..

37 Commits

Author SHA1 Message Date
xiaoju 586a0f824e chore: gitignore .npmrc (contains auth token) 2026-05-08 02:55:35 +00:00
xiaoju 178f6c7519 chore: bump all packages to 0.2.0 2026-05-08 02:55:29 +00:00
xiaomo 3153ab26f6 Merge pull request 'feat(engine): supervisor scene — opt-in LLM thread stop (Phase 3)' (#116) from feat/110-phase3-supervisor into main 2026-05-08 02:45:20 +00:00
xiaoju 014c442ed2 feat(engine): add supervisor scene — opt-in LLM-based thread stop (Phase 3)
Supervisor replaces maxRounds as primary stop mechanism. Every N rounds
(configurable via supervisorInterval, default 3), the engine calls a
cheap LLM to evaluate thread progress and decide continue/stop.

- New engine/supervisor.ts: runSupervisor + parseSupervisorDecisionText
- Supervisor is opt-in: no models.supervisor configured = always continue
- WorkflowConfig gains supervisorInterval (default 3, 0 to disable)
- Engine calls supervisor after each supervisorInterval rounds
- 256 tests pass, 14 new tests for supervisor logic

Refs #110
2026-05-08 02:38:54 +00:00
xingyue 1f7851d5e3 chore: remove outdated examples/ folder
Delete examples/ workspace and remove from workspaces config.
2026-05-08 10:32:57 +08:00
xiaomo e68790dfc7 Merge pull request 'chore: remove all deprecated code' (#115) from chore/114-remove-deprecated into main 2026-05-08 02:29:15 +00:00
xingyue 520b17b351 chore: remove all deprecated code
- Remove createThreadCas alias (CAS is global, not per-thread)
- Remove formatSkillDoc() legacy compat shim
- Remove help command (replaced by skill)
- Remove all 13 DEPRECATED_ALIASES flat commands + printDeprecation
- Fix CAS prompts in develop roles: remove stale <THREAD_ID> param
- Update README.md to remove createThreadCas reference
- Net: -86 lines, 241 tests pass

Closes #114
2026-05-08 10:27:27 +08:00
xiaomo 085cdcd3f4 Merge pull request 'feat: engine injects extract provider at runtime (Phase 2)' (#113) from feat/110-phase2-migrate-extract into main 2026-05-08 02:23:58 +00:00
xiaoju a8c1c158d6 feat: engine injects extract provider at runtime (Phase 2)
- createWorkflow(def, binding) — no more extract/llmProvider params
- Engine resolves extract provider from workflow.yaml via resolveModel
- WorkflowFnOptions now carries extract + llmProvider (engine-injected)
- Delete extract-provider.ts, inline maxDepth helper
- Template packages simplified: only take agent binding
- Breaking change: bundles no longer carry provider config

Refs #110
2026-05-08 02:21:45 +00:00
xiaomo 83649fd836 Merge pull request 'docs: add README.md to all 8 packages' (#112) from docs/package-readmes into main 2026-05-08 02:19:25 +00:00
xingyue a5c09adae6 docs: add README.md to all 8 packages
Each README includes: package description, install instructions,
usage example, API overview, and (for templates) role/moderator flow.

Packages documented:
- @uncaged/workflow (core)
- @uncaged/cli-workflow (CLI)
- @uncaged/workflow-agent-cursor
- @uncaged/workflow-agent-hermes
- @uncaged/workflow-agent-llm
- @uncaged/workflow-template-develop
- @uncaged/workflow-template-solve-issue
- @uncaged/workflow-util-agent
2026-05-08 10:17:40 +08:00
xiaomo 9e6cd9d615 Merge pull request 'feat: unified provider/model configuration (Phase 1)' (#111) from feat/110-phase1-config-layer into main 2026-05-08 02:15:23 +00:00
xiaoju 1f1128ff4a fix: address PR #111 review feedback
- Extract validateWorkspaceSegment to commands/init/validate.ts
- Unify splitProviderModelRef in config/, used by both resolve-model and registry-normalize
- Warn on missing models.default during parse (tag Z2KP9NWQ)
2026-05-08 02:14:20 +00:00
xiaoju aa01283ce1 feat: unified provider/model configuration (Phase 1)
- New src/config/ folder: resolveModel(config, scene) with fallback to default
- WorkflowConfig now has providers + models instead of extract
- Delete ExtractProviderConfig, getExtractProvider uses resolveModel('extract')
- New resolve-model tests, updated existing tests

Refs #110
2026-05-08 02:08:19 +00:00
xiaoju f81e2a8aac Merge pull request 'chore: enforce folder module discipline in @uncaged/cli-workflow' (#109) from chore/108-cli-module-discipline into main 2026-05-08 01:46:03 +00:00
xiaoju 2b38e583be chore: enforce folder module discipline in @uncaged/cli-workflow
Each commands/ subfolder (cas, init, thread, workflow) now has:
- types.ts for all type definitions
- index.ts with pure re-exports only
- External imports go through index.ts

Closes #108
2026-05-08 01:42:32 +00:00
xiaoju 4ff1394224 Merge pull request 'chore: enforce folder module discipline in @uncaged/workflow' (#107) from chore/106-workflow-module-discipline into main 2026-05-08 01:39:48 +00:00
xiaoju 2bbe5a3d0e chore: enforce folder module discipline in @uncaged/workflow
Each folder now has:
- types.ts for all type definitions
- index.ts with pure re-exports only
- Cross-folder imports go through index.ts

Closes #106
2026-05-08 01:37:23 +00:00
xiaoju a4237c0462 docs: add folder module discipline rules to CLAUDE.md
Four rules: index.ts entry point, types.ts for types, single export source,
index.ts is pure re-exports. Also fix stale build command reference.

Refs #102
2026-05-08 01:29:22 +00:00
xiaomo 321e5b1379 Merge pull request 'chore(cli): remove unused <thread-id> from CAS commands' (#105) from chore/cleanup-cas-thread-id into main 2026-05-08 01:25:33 +00:00
xingyue 7c3e14c473 chore(cli): remove unused <thread-id> from CAS commands
CAS is global (not per-thread). The underlying cmdCas* functions
already dropped threadId in #103, but the CLI dispatch layer still
required it from users. Now cleaned up:

- cas get <hash> (was: cas get <thread-id> <hash>)
- cas put <content> (was: cas put <thread-id> <content>)
- cas list (was: cas list <thread-id>)
- cas rm <hash> (was: cas rm <thread-id> <hash>)
- skill.ts develop topic updated to match
2026-05-08 09:23:39 +08:00
xiaoju aecce595e8 Merge pull request 'refactor: organize workflow/src into 6 module folders' (#104) from refactor/102-module-folders into main 2026-05-08 01:23:24 +00:00
xiaoju cf17dedac3 refactor: organize workflow/src into 6 module folders
Move 34 flat modules into cas/, registry/, bundle/, extract/, engine/, util/.
Move gc.ts to engine/ (was in cas/) to avoid cas→engine reverse dependency.
Dependency direction: util ← cas ← extract ← engine, util ← registry ← bundle.
No logic changes — only file locations and import paths.

Refs #102
2026-05-08 01:22:01 +00:00
xiaomo 661fdbb263 Merge pull request 'refactor(cli): Phase 4 cleanup — dedup, extract, deprecate' (#103) from refactor/97-phase4-cleanup into main 2026-05-08 01:17:17 +00:00
xingyue 201abf98ce refactor(cli): Phase 4 cleanup — dedup, extract, deprecate
- bundle-store.ts: remove private pathExists, import from fs-utils
- thread-scan.ts: extract parseFirstJsonLineObject helper, dedup first-line parsing
- commands/workflow/add-argv.ts: extract parseAddArgv from add.ts
- commands/thread/fork-argv.ts: extract parseForkArgv from fork.ts
- commands/cas/*.ts: remove unused _threadId params from cmdCas* functions
- cli-dispatch.ts: add deprecation warning to help command
- commands/init/templates.ts: extract template strings from template.ts
- cli-color.ts: extract shouldUseColor, highlightLiveRole, dimGreyLine from live.ts
- 242 tests pass, biome clean

Closes #97
2026-05-08 09:14:40 +08:00
xiaomo 665965fd01 Merge pull request 'refactor(cli): split cli-dispatch.ts into group dispatchers + usage module' (#101) from refactor/96-phase3-split-dispatch into main 2026-05-08 01:07:04 +00:00
xingyue 6a99f84025 refactor(cli): split cli-dispatch.ts into group dispatchers + usage module
- cli-dispatch.ts: 775 → 149 lines (top-level routing only)
- cli-usage.ts: usage formatting (formatCliUsage, formatUsageCommandLines)
- cli-command-types.ts: shared types (DispatchFn, CommandEntry, CommandGroup)
- cli-registry.ts: getCommandRegistry() assembling all group tables
- cli-usage-context.ts: decouple usage from registry (avoids circular deps)
- commands/{workflow,thread,cas,init}/dispatch.ts: group-specific dispatch
  functions + subcommand tables
- 242 tests pass, CLI output identical, biome clean

Refs #96
2026-05-08 09:04:27 +08:00
xiaomo f61474bec0 Merge pull request 'refactor(cli): merge kill/pause/resume into control.ts + extract readWorkerCtl' (#100) from refactor/95-phase2-control-merge into main 2026-05-08 00:58:11 +00:00
xingyue 9bdb18afd0 refactor(cli): merge kill/pause/resume into control.ts + extract readWorkerCtl
- Merge three near-identical files (kill.ts, pause.ts, resume.ts) into
  commands/thread/control.ts with parameterized cmdThreadControl()
- Extract readWorkerCtl() into worker-spawn.ts to eliminate duplicated
  WorkerCtl parsing logic
- Update cli-dispatch.ts and test imports
- Net reduction: ~59 lines

Refs #95
2026-05-08 08:55:25 +08:00
xiaomo 2af299f3ce Merge pull request 'refactor(cli): restructure cmd-*.ts into commands/ subdirectories' (#98) from refactor/93-phase1-directory-restructure into main 2026-05-08 00:48:30 +00:00
xiaoju d9f79c60a1 Merge pull request 'chore: remove unused build scripts' (#99) from chore/remove-build-scripts into main 2026-05-08 00:46:54 +00:00
xiaoju 485bfcb0b6 chore: remove unused build scripts
All packages are pure Bun/TS — no build step needed.
The build scripts were all placeholder `echo 'TODO'` anyway.
2026-05-08 00:46:35 +00:00
xiaoju a47ed06ea5 Merge pull request 'docs: create README.md, update architecture.md for current structure' (#89) from docs/88-readme-architecture-cleanup into main 2026-05-08 00:42:16 +00:00
xiaoju 2616259a0f Merge pull request 'feat(reviewer): enrich prompt with conventions + CLI awareness' (#92) from feat/91-reviewer-prompt into main 2026-05-07 16:27:08 +00:00
xiaoju 23b2c3b47d feat(reviewer): enrich prompt with conventions awareness + strict verdicts
- Read preparer's conventions from thread context
- Review checklist: correctness, conventions, consistency, edge cases
- No nits: every issue is blocking, approve only at zero issues
- Generic prompt, no workflow-specific concepts

Closes #91

小橘 🍊
2026-05-07 16:25:31 +00:00
xiaoju 7d3954097d docs: fix deprecated CLI commands in README
- workflow add (was: add)
- workflow list (was: list)
- thread list (was: threads)
- thread show (was: thread)

小橘 🍊
2026-05-07 16:15:29 +00:00
xiaoju 4a925b98af docs: create README.md, update architecture.md for current structure
- Create root README.md with project intro, concepts, packages, quickstart
- Remove workflow-role-* references from docs/architecture.md
- Roles now live inside template packages (src/roles/)
- Clean up untracked dist/packages/workflow-role-* remnants

Fixes #88
2026-05-07 16:10:37 +00:00
151 changed files with 3471 additions and 2069 deletions
+1
View File
@@ -3,3 +3,4 @@ dist/
bun.lock
*.tgz
tsconfig.tsbuildinfo
.npmrc
+31 -2
View File
@@ -97,6 +97,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 |
@@ -197,9 +227,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
```
+71
View File
@@ -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. Persisted as `.data.jsonl` + `.info.jsonl`. |
| **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.
+2 -5
View File
@@ -17,11 +17,8 @@ A workflow engine that executes single-file ESM bundles. Each workflow is a self
| `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-template-develop` | `@uncaged/workflow-template-develop` | Develop workflow template (roles in `src/roles/`) |
| `workflow-template-solve-issue` | `@uncaged/workflow-template-solve-issue` | Solve-issue workflow template (roles in `src/roles/`) |
| `workflow-util-agent` | `@uncaged/workflow-util-agent` | `buildAgentPrompt` + `spawnCli` utilities |
Monorepo with **bun workspace**, `workspace:*` protocol.
-53
View File
@@ -1,53 +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,
extractMode: "single",
};
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,
null,
);
-9
View File
@@ -1,9 +0,0 @@
{
"name": "@uncaged/workflow-examples",
"private": true,
"type": "module",
"dependencies": {
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
+1 -3
View File
@@ -2,11 +2,9 @@
"name": "@uncaged/workflow-monorepo",
"private": true,
"workspaces": [
"packages/*",
"examples"
"packages/*"
],
"scripts": {
"build": "bun run --filter '*' build",
"check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build",
"format": "biome format --write .",
+76
View File
@@ -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/commands/workflow/add.js";
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
return { name, filePath, typesPath: null };
@@ -4,16 +4,16 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
import { cmdCasGet } from "../src/commands/cas/get.js";
import { cmdCasList } from "../src/commands/cas/list.js";
import { cmdCasPut } from "../src/commands/cas/put.js";
import { cmdCasRm } from "../src/commands/cas/rm.js";
import { cmdAdd } from "../src/commands/workflow/add.js";
import { cmdHistory } from "../src/commands/workflow/history.js";
import { cmdList, formatListLines } from "../src/commands/workflow/list.js";
import { cmdRemove } from "../src/commands/workflow/rm.js";
import { cmdRollback } from "../src/commands/workflow/rollback.js";
import { cmdShow } from "../src/commands/workflow/show.js";
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: {} };
@@ -402,7 +402,7 @@ export const run = async function* (input, options) {
});
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 put = await cmdCasPut(storageRoot, "phase doc");
expect(put.ok).toBe(true);
if (!put.ok) {
return;
@@ -411,24 +411,24 @@ export const run = async function* (input, options) {
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
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");
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);
});
@@ -3,11 +3,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, getContentMerklePayload, getGlobalCasDir } from "@uncaged/workflow";
import { cmdFork } from "../src/commands/thread/fork.js";
import { cmdRun } from "../src/commands/thread/run.js";
import { cmdAdd } from "../src/commands/workflow/add.js";
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 { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -78,6 +78,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 () => {
@@ -10,7 +10,7 @@ import {
getGlobalCasDir,
putContentMerkleNode,
} from "@uncaged/workflow";
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
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));
+10 -22
View File
@@ -1,21 +1,11 @@
import { describe, expect, test } from "bun:test";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import {
formatSkillDoc,
formatSkillIndex,
formatSkillTopic,
getSkillTopics,
} from "../src/skill.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
const STORAGE_ROOT = "/tmp/help-test-storage";
describe("help command", () => {
test("help returns 0", async () => {
const code = await runCli(STORAGE_ROOT, ["help"]);
expect(code).toBe(0);
});
test("no args prints usage (not red) and returns 1", async () => {
describe("runCli usage", () => {
test("no args prints usage and returns 1", async () => {
const code = await runCli(STORAGE_ROOT, []);
expect(code).toBe(1);
});
@@ -70,13 +60,6 @@ describe("--help flag on groups", () => {
});
});
describe("legacy help --skill compat", () => {
test("help --skill still works (lists topics)", async () => {
const code = await runCli(STORAGE_ROOT, ["help", "--skill"]);
expect(code).toBe(0);
});
});
describe("getSkillTopics", () => {
test("returns all topics", () => {
const topics = getSkillTopics();
@@ -128,8 +111,13 @@ describe("formatCliUsage", () => {
});
});
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => {
const doc = formatSkillDoc();
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");
@@ -4,8 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { runCli } from "../src/cli-dispatch.js";
import { cmdInitTemplate } from "../src/commands/init/template.js";
import { cmdInitWorkspace } from "../src/commands/init/workspace.js";
import { cmdInitTemplate, cmdInitWorkspace } from "../src/commands/init/index.js";
import { pathExists } from "../src/fs-utils.js";
describe("init template", () => {
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import { cmdInitWorkspace } from "../src/commands/init/workspace.js";
import { cmdInitWorkspace } from "../src/commands/init/index.js";
import { pathExists } from "../src/fs-utils.js";
describe("init workspace", () => {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
LIVE_CONTENT_MAX_LINES,
type LiveRoleRow,
renderLiveRoleStepLines,
} from "../src/commands/thread/live.js";
} from "../src/commands/thread/index.js";
import { parseLiveArgv } from "../src/live-argv.js";
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
@@ -5,18 +5,21 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { getGlobalCasDir } from "@uncaged/workflow";
import { cmdCasPut } from "../src/commands/cas/put.js";
import { cmdKill } from "../src/commands/thread/kill.js";
import { cmdThreads } from "../src/commands/thread/list.js";
import { cmdPause } from "../src/commands/thread/pause.js";
import { cmdPs } from "../src/commands/thread/ps.js";
import { cmdResume } from "../src/commands/thread/resume.js";
import { cmdThreadRemove } from "../src/commands/thread/rm.js";
import { cmdRun } from "../src/commands/thread/run.js";
import { cmdThreadShow } from "../src/commands/thread/show.js";
import { cmdAdd } from "../src/commands/workflow/add.js";
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 { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
@@ -140,6 +143,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 () => {
@@ -234,7 +238,7 @@ describe("cli thread commands", () => {
const runningPath = join(dirname(dataPath), `${threadId}.running`);
await waitUntilRunningFileAbsent(runningPath, 120);
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;
@@ -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 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
@@ -10,7 +10,6 @@
"yaml": "^2.8.4"
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
}
}
+2 -9
View File
@@ -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";
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 };
+17
View File
@@ -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;
+19 -701
View File
@@ -1,613 +1,15 @@
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
import { cmdGc } from "./commands/cas/gc.js";
import { cmdCasGet } from "./commands/cas/get.js";
import { cmdCasList } from "./commands/cas/list.js";
import { cmdCasPut } from "./commands/cas/put.js";
import { cmdCasRm } from "./commands/cas/rm.js";
import { cmdInitTemplate } from "./commands/init/template.js";
import { cmdInitWorkspace } from "./commands/init/workspace.js";
import { cmdFork, parseForkArgv } from "./commands/thread/fork.js";
import { cmdKill } from "./commands/thread/kill.js";
import { cmdThreads } from "./commands/thread/list.js";
import { cmdLive } from "./commands/thread/live.js";
import { cmdPause } from "./commands/thread/pause.js";
import { cmdPs } from "./commands/thread/ps.js";
import { cmdResume } from "./commands/thread/resume.js";
import { cmdThreadRemove } from "./commands/thread/rm.js";
import { cmdRun } from "./commands/thread/run.js";
import { cmdThreadShow } from "./commands/thread/show.js";
import { cmdAdd, formatAddSuccess, parseAddArgv } from "./commands/workflow/add.js";
import { cmdHistory } from "./commands/workflow/history.js";
import { cmdList, formatListLines } from "./commands/workflow/list.js";
import { cmdRemove } from "./commands/workflow/rm.js";
import { cmdRollback } from "./commands/workflow/rollback.js";
import { cmdShow, formatShowYaml } from "./commands/workflow/show.js";
import { parseLiveArgv } from "./live-argv.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 { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
type DispatchFn = (storageRoot: string, argv: string[]) => Promise<number>;
type CommandEntry = {
handler: DispatchFn;
args: string;
description: string;
};
type CommandGroup = {
name: string;
commands: ReadonlyArray<{ name: string; args: string; description: string }>;
};
// ── Individual dispatch functions ──────────────────────────────────────
async function dispatchInitWorkspace(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\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;
}
async function dispatchInitTemplate(_storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\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;
}
async function dispatchAdd(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseAddArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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 dispatchLive(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseLiveArgv(argv);
if (!parsed.ok) {
printCliError(`${formatCliUsage()}\n\nerror: ${parsed.error}`);
return 1;
}
return cmdLive(storageRoot, parsed.value);
}
async function dispatchHistory(storageRoot: string, argv: string[]): Promise<number> {
const name = argv[0];
if (name === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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 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;
}
async function dispatchThreadShow(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\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;
}
async function dispatchThreadRm(storageRoot: string, argv: string[]): Promise<number> {
const id = argv[0];
if (id === undefined || argv.length > 1) {
printCliError(`${formatCliUsage()}\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 dispatchGc(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length > 0) {
printCliError(`${formatCliUsage()}\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(`${formatCliUsage()}\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;
}
// ── CAS subcommand table ───────────────────────────────────────────────
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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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(`${formatCliUsage()}\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;
}
// ── Subcommand tables with metadata ────────────────────────────────────
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",
},
};
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" },
};
const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
get: {
handler: dispatchCasGet,
args: "<thread-id> <hash>",
description: "Retrieve content by hash from a thread's CAS",
},
put: {
handler: dispatchCasPut,
args: "<thread-id> <content>",
description: "Store content in a thread's CAS, returns hash",
},
list: {
handler: dispatchCasList,
args: "<thread-id>",
description: "List all CAS entries for a thread",
},
rm: { handler: dispatchCasRm, args: "<thread-id> <hash>", description: "Remove a CAS entry" },
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
};
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",
},
};
// ── Command registry ───────────────────────────────────────────────────
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,
})),
},
];
}
// ── Auto-generated CLI usage ───────────────────────────────────────────
const USAGE_SECTION_BY_GROUP: Record<string, string> = {
workflow: "Workflow registry:",
thread: "Thread execution:",
cas: "Content-addressable storage:",
init: "Development:",
};
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(): string {
const groups = getCommandRegistry();
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("Reference:");
const skillTopicNames = getSkillTopics()
.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");
}
function printDeprecation(oldCmd: string, newCmd: string): void {
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
}
// ── Group dispatchers ──────────────────────────────────────────────────
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
export { getCommandRegistry } from "./cli-registry.js";
function dispatchGroup(
tableName: string,
@@ -634,54 +36,16 @@ function dispatchGroup(
return entry.handler(storageRoot, argv.slice(1));
}
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(`${formatCliUsage()}\n\nerror: unknown init subcommand: ${sub}`);
return 1;
export function formatCliUsage(): string {
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
}
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") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${formatCliUsage()}\n\nerror: unknown workflow subcommand: ${sub}`);
return 1;
}
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
const dispatchThread = createThreadDispatcher({ dispatchGroup });
const dispatchCas = createCasDispatcher({ dispatchGroup });
const dispatchInit = createInitDispatcher({ dispatchGroup });
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(`${formatCliUsage()}\n\nerror: unknown thread subcommand: ${sub}`);
return 1;
}
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(`${formatCliUsage()}\n\nerror: unknown cas subcommand: ${sub}`);
return 1;
}
// ── Help ────────────────────────────────────────────────────────────────
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
const topic = argv[0];
async function showSkillDocOrIndex(topic: string | undefined): Promise<number> {
if (topic === undefined) {
printCliLine(formatSkillIndex());
return 0;
@@ -695,60 +59,20 @@ async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<numb
return 0;
}
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
// Legacy compat: help --skill [topic] → skill [topic]
const skillIdx = argv.indexOf("--skill");
if (skillIdx !== -1) {
const topic = argv[skillIdx + 1];
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;
}
printCliLine(formatCliUsage());
return 0;
async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<number> {
return showSkillDocOrIndex(argv[0]);
}
// ── Top-level command table (Phase 3) ──────────────────────────────────
const COMMAND_TABLE: Record<string, DispatchFn> = {
// Grouped commands (primary)
workflow: dispatchWorkflow,
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
skill: dispatchSkill,
// Top-level shortcuts (no deprecation)
run: dispatchRun,
live: dispatchLive,
};
// Deprecated flat commands that delegate to grouped commands
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
add: { newCmd: "workflow add", handler: dispatchAdd },
list: { newCmd: "workflow list", handler: dispatchList },
show: { newCmd: "workflow show", handler: dispatchShow },
remove: { newCmd: "workflow rm", handler: dispatchRemove },
ps: { newCmd: "thread ps", handler: dispatchPs },
kill: { newCmd: "thread kill", handler: dispatchKill },
pause: { newCmd: "thread pause", handler: dispatchPause },
resume: { newCmd: "thread resume", handler: dispatchResume },
threads: { newCmd: "thread list", handler: dispatchThreadList },
fork: { newCmd: "thread fork", handler: dispatchFork },
gc: { newCmd: "cas gc", handler: dispatchGc },
history: { newCmd: "workflow history", handler: dispatchHistory },
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
if (argv.length === 0) {
printCliLine(formatCliUsage());
@@ -766,12 +90,6 @@ export async function runCli(storageRoot: string, argv: string[]): Promise<numbe
return dispatch(storageRoot, rest);
}
const deprecated = DEPRECATED_ALIASES[command];
if (deprecated !== undefined) {
printDeprecation(command, deprecated.newCmd);
return deprecated.handler(storageRoot, rest);
}
printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
return 1;
}
+45
View File
@@ -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;
}
+81
View File
@@ -0,0 +1,81 @@
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("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");
}
@@ -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;
};
}
@@ -2,7 +2,6 @@ import { createCasStore, err, getGlobalCasDir, ok, type Result } from "@uncaged/
export async function cmdCasGet(
storageRoot: string,
_threadId: string,
hash: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
@@ -1,3 +1,12 @@
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";
@@ -1,9 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasList(
storageRoot: string,
_threadId: string,
): Promise<Result<string[], string>> {
export async function cmdCasList(storageRoot: string): Promise<Result<string[], string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
const hashes = await cas.list();
return ok(hashes);
@@ -2,7 +2,6 @@ import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workf
export async function cmdCasPut(
storageRoot: string,
_threadId: string,
content: string,
): Promise<Result<string, string>> {
const cas = createCasStore(getGlobalCasDir(storageRoot));
+1 -5
View File
@@ -1,10 +1,6 @@
import { createCasStore, getGlobalCasDir, ok, type Result } from "@uncaged/workflow";
export async function cmdCasRm(
storageRoot: string,
_threadId: string,
hash: string,
): Promise<Result<void, string>> {
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;
};
}
@@ -1,4 +1,9 @@
export type { CmdInitTemplateSuccess } from "./template.js";
export {
createInitDispatcher,
dispatchInitTemplate,
dispatchInitWorkspace,
INIT_SUBCOMMAND_TABLE,
} from "./dispatch.js";
export { cmdInitTemplate } from "./template.js";
export type { CmdInitWorkspaceSuccess } from "./workspace.js";
export type { CmdInitTemplateSuccess, CmdInitWorkspaceSuccess } from "./types.js";
export { cmdInitWorkspace } from "./workspace.js";
@@ -5,22 +5,15 @@ import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "../../fs-utils.js";
export type CmdInitTemplateSuccess = {
templatePath: string;
};
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);
}
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/*");
@@ -67,108 +60,6 @@ async function findWorkflowWorkspaceRoot(startDir: string): Promise<Result<strin
}
}
function templatePackageJson(templateName: string): string {
return `${JSON.stringify(
{
name: `template-${templateName}`,
version: "0.0.0",
private: true,
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
zod: "^4.0.0",
},
},
null,
2,
)}\n`;
}
function templateTsconfigJson(): string {
return `${JSON.stringify(
{
extends: "../../tsconfig.json",
compilerOptions: {
rootDir: "src",
outDir: "dist",
},
include: ["src/**/*.ts"],
},
null,
2,
)}\n`;
}
function templateRolesTs(): string {
return `import type { RoleDefinition } from "@uncaged/workflow";
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,
};
`;
}
function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
import type { HelloTemplateMeta } from "./roles.js";
export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
ctx: ModeratorContext<HelloTemplateMeta>,
) => {
if (ctx.steps.length === 0) {
return "greeter";
}
return END;
};
`;
}
function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow";
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,
};
`;
}
export async function cmdInitTemplate(
startDir: string,
templateName: string,
@@ -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": "^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";
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";
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";
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";
/** 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);
}
@@ -4,23 +4,8 @@ import { join } from "node:path";
import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "../../fs-utils.js";
export type CmdInitWorkspaceSuccess = {
rootPath: string;
};
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);
}
import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify(
@@ -122,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn****ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -0,0 +1,52 @@
import type { Result } from "@uncaged/workflow";
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";
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 });
}
@@ -6,33 +6,6 @@ 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,
@@ -1,7 +1,21 @@
export { cmdFork, parseForkArgv } from "./fork.js";
export { cmdKill } from "./kill.js";
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 type { LiveRoleRow } from "./live.js";
export {
cmdLive,
formatLiveDebugLine,
@@ -9,9 +23,8 @@ export {
LIVE_CONTENT_MAX_LINES,
renderLiveRoleStepLines,
} from "./live.js";
export { cmdPause } from "./pause.js";
export { cmdPs } from "./ps.js";
export { cmdResume } from "./resume.js";
export { cmdThreadRemove } from "./rm.js";
export { cmdRun } from "./run.js";
export { cmdThreadShow } from "./show.js";
export type { LiveRoleRow } from "./types.js";
@@ -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 },
);
}
@@ -12,20 +12,15 @@ import {
type WorkflowCompletion,
} from "@uncaged/workflow";
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { printCliError, printCliLine } from "../../cli-output.js";
import { pathExists } from "../../fs-utils.js";
import type { ParsedLiveArgv } from "../../live-argv.js";
import { findLatestThreadDataPath, resolveThreadDataPath } from "../../thread-scan.js";
import type { LiveRoleRow } from "./types.js";
export const LIVE_CONTENT_MAX_LINES = 10;
export type LiveRoleRow = {
role: string;
content: string;
meta: Record<string, unknown>;
timestamp: number;
};
export function formatLiveTimeLabel(timestampMs: number): string {
const d = new Date(timestampMs);
const hh = String(d.getHours()).padStart(2, "0");
@@ -34,24 +29,6 @@ export function formatLiveTimeLabel(timestampMs: number): string {
return `${hh}:${mm}:${ss}`;
}
function shouldUseColor(): boolean {
return process.stdout.isTTY === true && process.env.NO_COLOR === undefined;
}
function highlightLiveRole(name: string): string {
if (!shouldUseColor()) {
return name;
}
return `\x1b[1m\x1b[36m${name}\x1b[0m`;
}
function dimGreyLine(line: string): string {
if (!shouldUseColor()) {
return line;
}
return `\x1b[2m\x1b[90m${line}\x1b[0m`;
}
export function formatLiveDebugLine(timestampMs: number, tag: string, message: string): string {
const label = `[${formatLiveTimeLabel(timestampMs)}] [${tag}] ${message.replace(/\n/g, " ")}`;
return dimGreyLine(label);
@@ -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 },
);
}
@@ -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";
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 });
}
@@ -17,17 +17,7 @@ import {
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 +27,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,
@@ -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;
};
}
@@ -1,7 +1,18 @@
export type { CmdAddSuccess, ParsedAddArgv } from "./add.js";
export { cmdAdd, formatAddSuccess, parseAddArgv } from "./add.js";
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";
@@ -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;
};
+5 -12
View File
@@ -1,4 +1,4 @@
import { getCommandRegistry } from "./cli-dispatch.js";
import { getCommandRegistry } from "./cli-registry.js";
type SkillTopic = {
name: string;
@@ -126,13 +126,13 @@ uncaged-workflow thread list
## CAS (Content-Addressable Storage)
Store and retrieve content by hash, scoped to the current thread.
Store and retrieve content by hash in workflow storage (global CAS directory).
| Operation | Command |
|-----------|---------|
| **Store** | \`uncaged-workflow cas put <THREAD_ID> '<content>'\` → prints hash |
| **Read** | \`uncaged-workflow cas get <THREAD_ID> <HASH>\` → prints content |
| **List** | \`uncaged-workflow cas list <THREAD_ID>\` |
| **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.
@@ -229,10 +229,3 @@ uncaged-workflow live --latest
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.
`;
}
// ── Legacy compat ──────────────────────────────────────────────────────
/** @deprecated Use formatSkillTopic("cli") instead */
export function formatSkillDoc(): string {
return formatSkillCli();
}
+23 -24
View File
@@ -3,6 +3,23 @@ import { join } from "node:path";
import { pathExists, readTextFileIfExists } from "./fs-utils.js";
function parseFirstJsonLineObject(text: string): Record<string, unknown> | null {
const firstLine = text.split("\n")[0];
if (firstLine === undefined || firstLine.trim() === "") {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
return parsed as Record<string, unknown>;
}
export type RunningThreadRow = {
threadId: string;
hash: string;
@@ -20,20 +37,11 @@ async function readThreadStartTimestampMs(dataPath: string): Promise<number | nu
if (text === null) {
return null;
}
const firstLine = text.split("\n")[0];
if (firstLine === undefined || firstLine.trim() === "") {
const parsed = parseFirstJsonLineObject(text);
if (parsed === null) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
const ts = (parsed as Record<string, unknown>).timestamp;
const ts = parsed.timestamp;
return typeof ts === "number" && Number.isFinite(ts) ? ts : null;
}
@@ -42,20 +50,11 @@ async function readWorkflowNameFromDataJsonl(dataPath: string): Promise<string |
if (text === null) {
return null;
}
const firstLine = text.split("\n")[0];
if (firstLine === undefined || firstLine.trim() === "") {
const parsed = parseFirstJsonLineObject(text);
if (parsed === null) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(firstLine) as unknown;
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") {
return null;
}
const name = (parsed as Record<string, unknown>).name;
const name = parsed.name;
return typeof name === "string" ? name : null;
}
+24
View File
@@ -237,6 +237,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,
+36
View File
@@ -0,0 +1,36 @@
# @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 @uncaged/workflow-util-agent zod
```
In this monorepo: `"@uncaged/workflow-agent-cursor": "workspace:*"` plus `workspace:*` for `@uncaged/workflow` and `@uncaged/workflow-util-agent`.
## 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)` |
| `CursorAgentConfig` | `model`, `timeout`, `extract` (must supply workspace path) |
| `validateCursorAgentConfig` | Config validation result |
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
Requires `cursor-agent` on `PATH` at runtime.
+1 -2
View File
@@ -1,11 +1,10 @@
{
"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": {
+35
View File
@@ -0,0 +1,35 @@
# @uncaged/workflow-agent-hermes
`AgentFn` adapter that runs the `hermes` CLI in non-interactive `chat` mode (Nerve-style flags: `-q`, `--yolo`, `--quiet`, bounded `--max-turns`).
The agent composes the same thread-aware prompt as other CLI-backed agents via `buildAgentPrompt` from `@uncaged/workflow-util-agent`, then spawns `hermes` and returns stdout on success.
## Install
```bash
bun add @uncaged/workflow-agent-hermes @uncaged/workflow @uncaged/workflow-util-agent
```
In this monorepo: use `workspace:*` for all three `@uncaged/*` packages.
## Usage
```typescript
import { createHermesAgent } from "@uncaged/workflow-agent-hermes";
const agent = createHermesAgent({
model: "your-model", // or null to omit --model
timeout: 600_000, // ms, or null for no timeout
});
```
## API overview
| Export | Description |
|--------|-------------|
| `createHermesAgent(config)` | Returns `AgentFn` wrapping `hermes chat -q ...` |
| `HermesAgentConfig` | `model`, `timeout` |
| `validateHermesAgentConfig` | Config validation result |
| `buildAgentPrompt` | Re-exported from `@uncaged/workflow-util-agent` |
Requires `hermes` on `PATH` at runtime.
+1 -2
View File
@@ -1,11 +1,10 @@
{
"name": "@uncaged/workflow-agent-hermes",
"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": {
+34
View File
@@ -0,0 +1,34 @@
# @uncaged/workflow-agent-llm
`AgentFn` adapter that calls an OpenAI-compatible `POST /chat/completions` endpoint using `@uncaged/workflow`’s `LlmProvider` (base URL, API key, model).
Single-turn: system text is the current role’s `systemPrompt`, user text is the thread’s initial prompt (`ctx.start.content`). Errors from HTTP, JSON, or empty choices are thrown as `Error` with a JSON payload string.
## Install
```bash
bun add @uncaged/workflow-agent-llm @uncaged/workflow
```
In this monorepo: `"@uncaged/workflow-agent-llm": "workspace:*"`, `"@uncaged/workflow": "workspace:*"`.
## Usage
```typescript
import { createLlmAdapter } from "@uncaged/workflow-agent-llm";
const agent = createLlmAdapter({
baseUrl: "https://api.openai.com/v1",
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-4.1-mini",
});
```
## API overview
| Export | Description |
|--------|-------------|
| `createLlmAdapter(provider)` | `LlmProvider``AgentFn` |
| `chatCompletionText({ provider, messages })` | Low-level `Result<string, LlmChatError>` helper |
| `LlmMessage` | `{ role: "system" \| "user" \| "assistant"; content: string }` |
| `LlmChatError` | Discriminated error kinds for failed completions |
+1 -2
View File
@@ -1,11 +1,10 @@
{
"name": "@uncaged/workflow-agent-llm",
"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": {
@@ -0,0 +1,53 @@
# @uncaged/workflow-template-develop
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
Export a `WorkflowDefinition` and `createDevelopRun` so a host can bind agents/LLM and run the same graph the bundled `.esm.js` would use. Use `buildDevelopDescriptor()` when assembling `descriptor` metadata for a bundle.
## Install
```bash
bun add @uncaged/workflow-template-develop @uncaged/workflow zod
```
In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@uncaged/workflow`.
## Usage
```typescript
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop";
const run = createDevelopRun(binding, extract, llmProvider);
// run(...) executes the develop moderator graph with your AgentBinding
```
## Roles
| Role | Purpose |
|------|---------|
| **planner** | Break work into ordered phases (hashes) |
| **coder** | Implement current phase; repeats until phases complete or limits hit |
| **reviewer** | Code review gate (`approved` vs send back to coder) |
| **tester** | Verify via tests/build/lint (`passed` vs send back to coder) |
| **committer** | Final commit step |
Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `DevelopMeta`, `developRoles`.
## Moderator flow
1. **Start**`planner`
2. After **planner**`coder`
3. After **coder** → if all planned phases are done (or last phase completed) → `reviewer`; else `coder` again, until `maxRounds` then `END`
4. After **reviewer** → if approved → `tester`; else `coder` (or `END` if out of rounds)
5. After **tester** → if passed → `committer`; else `coder` (or `END` if out of rounds)
6. After **committer**`END`
## API overview
| Export | Description |
|--------|-------------|
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
| `developModerator` | `Moderator<DevelopMeta>` |
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
| `DEVELOP_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
@@ -1,11 +1,10 @@
{
"name": "@uncaged/workflow-template-develop",
"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": {
@@ -1,8 +1,6 @@
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
@@ -43,10 +41,6 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
moderator: developModerator,
};
export function createDevelopRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
export function createDevelopRun(binding: AgentBinding): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding);
}
@@ -15,7 +15,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
## Reading phase details
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`.
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
@@ -18,7 +18,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
## Storing phase details — MANDATORY
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier.
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
@@ -12,8 +12,27 @@ export const reviewerMetaSchema = z.discriminatedUnion("status", [
]);
export type ReviewerMeta = z.infer<typeof reviewerMetaSchema>;
const REVIEWER_SYSTEM = `You are a code reviewer. Review the current git diff. Give a clear approve or reject verdict.
Only reject for blocking issues. End with your verdict.`;
const REVIEWER_SYSTEM = `You are a code reviewer. Review the git diff for correctness, consistency, and adherence to project conventions.
## Review process
1. Read the **preparer**'s output in the thread for project conventions (coding style, naming, commit format, etc.).
2. Review the diff against these conventions.
3. For documentation changes, verify that names, paths, and references match the actual codebase.
## Review checklist
- **Correctness** — does the code do what it claims? Logic bugs, off-by-one, missing returns?
- **Conventions** — naming, imports, code style per project rules?
- **Consistency** — do docs/comments match actual code? Are references current and accurate?
- **Edge cases** — missing error handling, null checks, boundary conditions?
## Verdict
- **Approve** only if there are zero issues
- **Reject** with specific issues that must be fixed — every issue you find is blocking
Be thorough. A false approve costs more than a false reject.`;
export const reviewerRole: RoleDefinition<ReviewerMeta> = {
description: "Runs git diff checks and sets approved when the change is ready.",
@@ -0,0 +1,48 @@
# @uncaged/workflow-template-solve-issue
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
`createSolveIssueRun` wires the `developer` role to `workflowAsAgent("develop")` by default; `binding.overrides.developer` wins if you pass one (for tests or custom hosts).
## Install
```bash
bun add @uncaged/workflow-template-solve-issue @uncaged/workflow zod
```
In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
## Usage
```typescript
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
const run = createSolveIssueRun(binding, extract, llmProvider);
```
## Roles
| Role | Purpose |
|------|---------|
| **preparer** | Set up context / repo state for the issue |
| **developer** | Implementation; default runs the registered `develop` workflow as a sub-agent |
| **submitter** | Finalize and submit the outcome (e.g. PR) |
Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod meta schemas, `SolveIssueMeta`, `solveIssueRoles`.
## Moderator flow
1. **Start**`preparer`
2. After **preparer**`developer`
3. After **developer**`submitter`
4. After **submitter**`END`
## API overview
| Export | Description |
|--------|-------------|
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
| `buildSolveIssueDescriptor` | Descriptor helper for bundles |
| `SOLVE_ISSUE_WORKFLOW_DESCRIPTION` | Human-readable one-liner |
@@ -250,17 +250,20 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow.
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
},
stubExtract,
stubLlmProvider,
);
const run = createSolveIssueRun({
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
const first = await gen.next();
expect(first.done).toBe(false);
@@ -294,33 +297,36 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
const calls: string[] = [];
const run = createSolveIssueRun(
{
agent: async () => {
calls.push("default");
const run = createSolveIssueRun({
agent: async () => {
calls.push("default");
return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
developer: async () => {
calls.push("developer");
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
},
stubExtract,
stubLlmProvider,
);
});
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
await gen.next();
expect(calls).toEqual(["preparer"]);
@@ -353,22 +359,25 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir);
let developerInvocations = 0;
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
const run = createSolveIssueRun({
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
},
stubExtract,
stubLlmProvider,
);
});
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
{
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
);
// preparer
await gen.next();
@@ -1,11 +1,10 @@
{
"name": "@uncaged/workflow-template-solve-issue",
"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": {
@@ -1,8 +1,6 @@
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
workflowAsAgent,
@@ -46,11 +44,7 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/
export function createSolveIssueRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = {
agent: binding.agent,
@@ -59,5 +53,5 @@ export function createSolveIssueRun(
developer: developerOverride,
},
};
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding);
}
+34
View File
@@ -0,0 +1,34 @@
# @uncaged/workflow-util-agent
Shared helpers for CLI-backed workflow agents: assemble prompts from thread context and spawn subprocesses with timeouts.
Used by `@uncaged/workflow-agent-cursor` and `@uncaged/workflow-agent-hermes`. Depends on `@uncaged/workflow` for CAS reads (`getContentMerklePayload`) and `Result` typing.
## Install
```bash
bun add @uncaged/workflow-util-agent @uncaged/workflow
```
In this monorepo: `workspace:*` for both packages.
## Usage
```typescript
import { buildAgentPrompt, spawnCli } from "@uncaged/workflow-util-agent";
const prompt = await buildAgentPrompt(agentContext);
const result = await spawnCli("my-cli", ["--json"], { cwd: "/tmp", timeoutMs: 60_000 });
if (!result.ok) { /* handle SpawnCliError */ }
const stdout = result.value;
```
## API overview
| Export | Description |
|--------|-------------|
| `buildAgentPrompt(ctx)` | System prompt + task + prior step summaries + latest body from CAS; appends `uncaged-workflow thread <id>` tool hint |
| `spawnCli(cmd, args, { cwd, timeoutMs })` | `Promise<Result<string, SpawnCliError>>`; captures stdout, non-zero exit and spawn failures as `err` |
| `SpawnCliConfig` | `cwd: string \| null`, `timeoutMs: number \| null` |
| `SpawnCliError` | `non_zero_exit` \| `timeout` \| `spawn_failed` |
| `SpawnCliResult` | Alias for `Result<string, SpawnCliError>` |
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-util-agent",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -11,7 +11,6 @@
}
},
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
+36
View File
@@ -0,0 +1,36 @@
# @uncaged/workflow
Core workflow engine: registry, CAS, thread execution, bundle validation, and role/workflow types.
This package implements the three-phase engine loop that runs single-file ESM workflow bundles (each exports `run` and `descriptor`). It persists threads under `~/.uncaged/workflow/` by default and hashes bundles with XXH64 (Crockford Base32). See the repo root [README](../../README.md) for workflow, bundle, thread, role, and registry concepts.
## Install
```bash
bun add @uncaged/workflow zod
```
In this monorepo, depend with `"@uncaged/workflow": "workspace:*"`. `zod` is a peer dependency (used by bundle/shape validation at the integration boundary).
## Usage
```typescript
import { createWorkflow, readWorkflowRegistry, executeThread } from "@uncaged/workflow";
// Wire a WorkflowDefinition + AgentBinding + extract + optional LlmProvider into createWorkflow,
// then run the returned WorkflowFn inside your host (or use executeThread for disk-backed runs).
```
## API overview
| Area | Exports (representative) |
|------|--------------------------|
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
| **CAS** | `createCasStore`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
| **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` |
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
| **Utilities** | `createLogger`, ULID / Crockford Base32 codecs, `getDefaultWorkflowStorageRoot`, paths |
Full surface is re-exported from `src/index.ts`.
+1 -1
View File
@@ -5,7 +5,7 @@ import {
decodeCrockfordToUint64,
encodeCrockfordBase32Bits,
encodeUint64AsCrockford,
} from "../src/base32.js";
} from "../src/util/base32.js";
describe("Crockford Base32", () => {
test("roundtrip 64-bit hash encoding", () => {
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import * as z from "zod/v4";
import { buildDescriptor } from "../src/build-descriptor.js";
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
import { END } from "../src/types.js";
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js";
describe("buildDescriptor", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { validateWorkflowBundle } from "../src/bundle-validator.js";
import { validateWorkflowBundle } from "../src/bundle/bundle-validator.js";
const minimalDescriptor = `export const descriptor = { description: "x", roles: {} };
`;
+2 -8
View File
@@ -3,14 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, createThreadCas } from "../src/cas.js";
import { hashString } from "../src/hash.js";
describe("cas module exports", () => {
test("createThreadCas is a deprecated alias of createCasStore", () => {
expect(createThreadCas).toBe(createCasStore);
});
});
import { createCasStore } from "../src/cas/cas.js";
import { hashString } from "../src/cas/hash.js";
describe("createCasStore", () => {
let casDir: string;
+220 -20
View File
@@ -1,21 +1,20 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js";
import { createWorkflow } from "../src/create-workflow.js";
import { executeThread } from "../src/engine.js";
import { createExtract } from "../src/extract-fn.js";
import { createLogger } from "../src/logger.js";
import { createCasStore } from "../src/cas/cas.js";
import {
createContentMerkleNode,
getContentMerklePayload,
parseMerkleNode,
serializeMerkleNode,
} from "../src/merkle.js";
import { END, type LlmProvider } from "../src/types.js";
} from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({
plan: z.string(),
@@ -82,11 +81,112 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
};
}
const demoExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const EXTRACT_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
}
const SUPERVISOR_INTERVAL_REGISTRY_YAML = `config:
maxDepth: 3
supervisorInterval: 2
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
extract: stub/model
supervisor: stub/supervisor-cheap
workflows: {}
`;
const SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML = `config:
maxDepth: 3
supervisorInterval: 10
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
extract: stub/model
supervisor: stub/supervisor-cheap
workflows: {}
`;
async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
}
/** Extract rounds use tool_calls; supervisor uses plain `content` (no tools). */
function installMockExtractThenSupervisor(params: {
extractArgs: ReadonlyArray<Record<string, unknown>>;
supervisorContent: string;
onSupervisorCall?: () => void;
}): () => void {
const origFetch = globalThis.fetch;
let extractI = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
init?: RequestInit,
): Promise<Response> => {
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const hasTools = Array.isArray(tools) && tools.length > 0;
if (hasTools) {
const args =
params.extractArgs[extractI] ?? params.extractArgs[params.extractArgs.length - 1];
if (args === undefined) {
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
}
extractI += 1;
const firstTool = tools[0] as Record<string, unknown>;
const fn = firstTool.function as Record<string, unknown> | undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
params.onSupervisorCall?.();
return new Response(
JSON.stringify({
choices: [{ message: { content: params.supervisorContent } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
const demoWorkflow = createWorkflow<DemoMeta>(
{
@@ -125,8 +225,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
coder: async () => "code-body",
},
},
demoExtract,
null,
);
describe("executeThread", () => {
@@ -150,6 +248,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -166,6 +265,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -258,6 +358,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas"));
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
@@ -295,6 +396,7 @@ describe("executeThread", () => {
timestamp: histTs,
},
],
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -354,6 +456,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -391,6 +494,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -407,6 +511,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -549,9 +654,6 @@ describe("executeThread", () => {
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
const extractFn = createExtract(llm);
const dagWorkflow = createWorkflow<DagDemoMeta>(
{
roles: {
@@ -568,8 +670,6 @@ describe("executeThread", () => {
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
},
{ agent: async () => dagRootHash },
extractFn,
llm,
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -577,6 +677,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
@@ -592,6 +693,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -613,4 +715,102 @@ describe("executeThread", () => {
await rm(root, { recursive: true, force: true });
}
});
test("supervisor stops thread when interval elapses and model returns stop", async () => {
restoreFetch = installMockExtractThenSupervisor({
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
supervisorContent: "stop",
});
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-stop-"));
try {
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
const hash = "C9NMV6V2TQT81";
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeRegistryYaml(root, SUPERVISOR_INTERVAL_REGISTRY_YAML);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
const result = await executeThread(
demoWorkflow,
"demo-flow",
{ prompt: "supervisor-stop-case", steps: [] },
{
maxRounds: 20,
depth: 0,
signal: ac.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
);
expect(result.returnCode).toBe(0);
expect(result.summary).toBe("completed: supervisor stopped thread");
const dataText = await readFile(dataPath, "utf8");
const lines = dataText
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(3);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("supervisor is not invoked before supervisorInterval rounds", async () => {
let supervisorCalls = 0;
restoreFetch = installMockExtractThenSupervisor({
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
supervisorContent: "stop",
onSupervisorCall: () => {
supervisorCalls += 1;
},
});
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-skip-"));
try {
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
const hash = "C9NMV6V2TQT81";
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeRegistryYaml(root, SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
const result = await executeThread(
demoWorkflow,
"demo-flow",
{ prompt: "no-supervisor-yet", steps: [] },
{
maxRounds: 20,
depth: 0,
signal: ac.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
);
expect(supervisorCalls).toBe(0);
expect(result.returnCode).toBe(0);
expect(result.summary).toBe("completed: moderator returned END");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getExtractProvider } from "../src/extract-provider.js";
describe("getExtractProvider", () => {
test("returns provider when config.extract is present", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: literal-key
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
expect(r.value.model).toBe("qwen-plus");
expect(r.value.apiKey).toBe("literal-key");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("errs when registry has no config section", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
const r = await getExtractProvider(root);
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no global config");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("resolves apiKey from env at registry read time", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.apiKey).toBe("resolved-secret");
} finally {
if (prev === undefined) {
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
} else {
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
}
await rm(root, { recursive: true, force: true });
}
});
});
@@ -4,7 +4,7 @@ import {
buildForkPlan,
parseThreadDataJsonl,
selectForkHistoricalSteps,
} from "../src/fork-thread.js";
} from "../src/engine/fork-thread.js";
const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
{"role":"planner","contentHash":"HP0000000000000000000001","meta":{},"refs":[],"timestamp":101}
+2 -3
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import { decodeCrockfordToUint64 } from "../src/base32.js";
import { hashWorkflowBundleBytes } from "../src/hash.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { decodeCrockfordToUint64 } from "../src/util/base32.js";
describe("hashWorkflowBundleBytes", () => {
test("matches XXH64 reference for empty input", () => {
+1 -1
View File
@@ -3,7 +3,7 @@ import { mkdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createLogger } from "../src/logger.js";
import { createLogger } from "../src/util/logger.js";
describe("createLogger", () => {
test("writes JSONL records to a file sink", async () => {
+5 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "../src/merkle.js";
import {
createContentMerkleNode,
parseMerkleNode,
serializeMerkleNode,
} from "../src/cas/merkle.js";
describe("merkle", () => {
test("content node roundtrips through YAML", () => {
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
import { reactExtract } from "../src/react-extract.js";
import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { reactExtract } from "../src/extract/react-extract.js";
import type { LlmProvider } from "../src/types.js";
const metaSchema = z.object({ seen: z.string() });
@@ -1,16 +1,15 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js";
import { createWorkflow } from "../src/create-workflow.js";
import { executeThread } from "../src/engine.js";
import { createExtract } from "../src/extract-fn.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/fork-thread.js";
import { createLogger } from "../src/logger.js";
import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
const phaseSchema = z.object({
hash: z.string(),
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
};
}
const refsDemoExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const EXTRACT_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{
@@ -99,8 +103,6 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{
agent: async () => "plan-output",
},
refsDemoExtract,
null,
);
describe("RoleStep refs tracking", () => {
@@ -142,6 +144,7 @@ describe("RoleStep refs tracking", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeFile(join(root, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -158,6 +161,7 @@ describe("RoleStep refs tracking", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
+85 -17
View File
@@ -10,7 +10,7 @@ import {
rollbackWorkflowToHistoryHash,
unregisterWorkflow,
writeWorkflowRegistry,
} from "../src/registry.js";
} from "../src/registry/registry.js";
describe("workflow registry", () => {
test("roundtrips through workflow.yaml", async () => {
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
const yaml = `
config:
maxDepth: 3
extract:
baseUrl: https://example.com/v1
model: qwen-plus
apiKey: secret-key
providers:
dashscope:
baseUrl: https://example.com/v1
apiKey: secret-key
models:
default: dashscope/qwen-turbo
extract: dashscope/qwen-plus
workflows:
solve-issue:
hash: SPVR4BDMSGC1W
@@ -125,9 +128,69 @@ workflows:
return;
}
expect(r.value.config.maxDepth).toBe(3);
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.extract.model).toBe("qwen-plus");
expect(r.value.config.extract.apiKey).toBe("secret-key");
expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
expect(r.value.config.supervisorInterval).toBe(3);
});
test("defaults supervisorInterval to 3 when omitted", () => {
const yaml = `
config:
maxDepth: 0
providers:
p:
baseUrl: https://example.com
apiKey: k
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(true);
if (!r.ok || r.value.config === null) {
return;
}
expect(r.value.config.supervisorInterval).toBe(3);
});
test("parses explicit supervisorInterval", () => {
const yaml = `
config:
maxDepth: 0
supervisorInterval: 7
providers:
p:
baseUrl: https://example.com
apiKey: k
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(true);
if (!r.ok || r.value.config === null) {
return;
}
expect(r.value.config.supervisorInterval).toBe(7);
});
test("parse errors when supervisorInterval is negative", () => {
const yaml = `
config:
maxDepth: 0
supervisorInterval: -1
providers:
p:
baseUrl: https://example.com
apiKey: k
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(false);
});
test("parses config apiKey env: prefix from process.env", () => {
@@ -137,10 +200,13 @@ workflows:
const yaml = `
config:
maxDepth: 1
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: env:WF_REGISTRY_TEST_API_KEY
providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
apiKey: env:WF_REGISTRY_TEST_API_KEY
models:
default: dashscope/qwen-plus
extract: dashscope/qwen-plus
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
@@ -148,7 +214,7 @@ workflows: {}
if (!r.ok) {
return;
}
expect(r.value.config?.extract.apiKey).toBe("from-env");
expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
} finally {
if (prev === undefined) {
delete process.env.WF_REGISTRY_TEST_API_KEY;
@@ -165,10 +231,12 @@ workflows: {}
const yaml = `
config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
providers:
p:
baseUrl: https://example.com
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
@@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test";
import { resolveModel } from "../src/config/resolve-model.js";
import type { WorkflowConfig } from "../src/registry/index.js";
function sampleConfig(): WorkflowConfig {
return {
maxDepth: 3,
supervisorInterval: 3,
providers: {
dashscope: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "secret",
},
other: {
baseUrl: "https://other.example/v1",
apiKey: "k2",
},
},
models: {
default: "dashscope/qwen-plus",
extract: "other/foo/bar-model",
},
};
}
describe("resolveModel", () => {
test("uses explicit scene mapping", () => {
const config = sampleConfig();
const r = resolveModel(config, "extract");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://other.example/v1");
expect(r.value.apiKey).toBe("k2");
expect(r.value.model).toBe("foo/bar-model");
});
test("falls back to models.default when scene is missing", () => {
const config = sampleConfig();
const r = resolveModel(config, "unknown-scene");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.model).toBe("qwen-plus");
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
});
test("errs when scene missing and no default", () => {
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
extract: "p/m",
},
};
const r = resolveModel(config, "other");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no model mapping");
expect(r.error).toContain("default");
});
test("errs when provider is unknown", () => {
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "missing/m",
},
};
const r = resolveModel(config, "any");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("unknown provider");
});
test("errs on invalid model reference shape", () => {
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "no-slash-model",
},
};
const r = resolveModel(config, "x");
expect(r.ok).toBe(false);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { err, ok } from "../src/result.js";
import { err, ok } from "../src/util/result.js";
describe("result helpers", () => {
test("ok wraps value", () => {
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "../src/storage-root.js";
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "../src/util/storage-root.js";
describe("getGlobalCasDir", () => {
test("joins cas segment under explicit storage root", () => {
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, test } from "bun:test";
import { parseSupervisorDecisionText, runSupervisor } from "../src/engine/supervisor.js";
import type { WorkflowConfig } from "../src/registry/index.js";
import type { LogFn } from "../src/util/index.js";
const noopLogger: LogFn = () => {};
function supervisorOnlyConfig(): WorkflowConfig {
return {
maxDepth: 3,
supervisorInterval: 3,
providers: {
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
},
models: {
extract: "stub/extract-model",
supervisor: "stub/supervisor-model",
},
};
}
describe("parseSupervisorDecisionText", () => {
test("reads continue and stop case-insensitively", () => {
expect(parseSupervisorDecisionText("continue")).toBe("continue");
expect(parseSupervisorDecisionText("CONTINUE")).toBe("continue");
expect(parseSupervisorDecisionText("stop")).toBe("stop");
expect(parseSupervisorDecisionText("STOP.")).toBe("stop");
});
test("finds token inside a sentence", () => {
expect(parseSupervisorDecisionText("Answer: continue")).toBe("continue");
expect(parseSupervisorDecisionText("I recommend stop now")).toBe("stop");
});
test("when both appear, earlier token wins", () => {
expect(parseSupervisorDecisionText("continue then stop")).toBe("continue");
expect(parseSupervisorDecisionText("stop then continue")).toBe("stop");
});
test("defaults to continue when unclear", () => {
expect(parseSupervisorDecisionText("maybe later")).toBe("continue");
});
});
describe("runSupervisor", () => {
let restoreFetch: (() => void) | null = null;
afterEach(() => {
restoreFetch?.();
restoreFetch = null;
});
test("returns continue when supervisor model cannot be resolved (no fetch)", async () => {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async () => {
throw new Error("fetch should not run when supervisor is not configured");
},
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
},
models: {
extract: "stub/m",
},
};
const r = await runSupervisor({
config,
prompt: "task",
recentSteps: [{ role: "planner", summary: "{}" }],
logger: noopLogger,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value).toBe("continue");
});
test("returns stop from chat/completions assistant content", async () => {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async () =>
new Response(
JSON.stringify({
choices: [{ message: { content: "stop" } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const r = await runSupervisor({
config: supervisorOnlyConfig(),
prompt: "do X",
recentSteps: [{ role: "a", summary: "{}" }],
logger: noopLogger,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value).toBe("stop");
});
test("returns err on invalid JSON body", async () => {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(async () => new Response("not-json", { status: 200 }), {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
const r = await runSupervisor({
config: supervisorOnlyConfig(),
prompt: "p",
recentSteps: [],
logger: noopLogger,
});
expect(r.ok).toBe(false);
});
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { createThreadPauseGate } from "../src/thread-pause-gate.js";
import { createThreadPauseGate } from "../src/engine/thread-pause-gate.js";
describe("createThreadPauseGate", () => {
test("pause blocks awaitAfterYield until resume", async () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { decodeCrockfordBase32Bits } from "../src/base32.js";
import { generateUlid } from "../src/ulid.js";
import { decodeCrockfordBase32Bits } from "../src/util/base32.js";
import { generateUlid } from "../src/util/ulid.js";
describe("generateUlid", () => {
test("length and decodable Crockford payload", () => {
+16 -3
View File
@@ -5,9 +5,20 @@ import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "../src/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js";
import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
const WORKER_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -89,6 +100,7 @@ describe("worker process", () => {
try {
const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8");
@@ -136,6 +148,7 @@ describe("worker process", () => {
try {
const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8");
@@ -4,19 +4,18 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js";
import { createWorkflow } from "../src/create-workflow.js";
import { executeThread } from "../src/engine.js";
import { createExtract } from "../src/extract-fn.js";
import { hashWorkflowBundleBytes } from "../src/hash.js";
import { createLogger } from "../src/logger.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/merkle.js";
import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js";
import {
readWorkflowRegistry,
registerWorkflowVersion,
writeWorkflowRegistry,
} from "../src/registry.js";
} from "../src/registry/registry.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js";
const callerMetaSchema = z.object({ done: z.literal(true) });
@@ -76,11 +75,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
};
}
const parentExtract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
});
const PARENT_REGISTRY_WITH_CONFIG = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -131,6 +135,8 @@ describe("workflowAsAgent integration", () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
const { hash: childHash } = await installChildWorkflow(root);
const parentWorkflow = createWorkflow<ParentMeta>(
@@ -148,8 +154,6 @@ describe("workflowAsAgent integration", () => {
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
},
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
parentExtract,
null,
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -173,6 +177,7 @@ describe("workflowAsAgent integration", () => {
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
@@ -3,14 +3,14 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore } from "../src/cas.js";
import { hashWorkflowBundleBytes } from "../src/hash.js";
import { parseMerkleNode } from "../src/merkle.js";
import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { parseMerkleNode } from "../src/cas/merkle.js";
import {
readWorkflowRegistry,
registerWorkflowVersion,
writeWorkflowRegistry,
} from "../src/registry.js";
} from "../src/registry/registry.js";
import { type AgentContext, START } from "../src/types.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js";
@@ -93,6 +93,21 @@ describe("workflowAsAgent", () => {
test("runs registered workflow and returns child thread root CAS hash", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`,
"utf8",
);
await installChildWorkflow(root);
const agent = workflowAsAgent("child-wf", { storageRoot: root });
const out = await agent(
@@ -140,10 +155,16 @@ describe("workflowAsAgent", () => {
...reg.value,
config: {
maxDepth: 2,
extract: {
baseUrl: "http://127.0.0.1:9",
model: "m",
apiKey: "k",
supervisorInterval: 3,
providers: {
local: {
baseUrl: "http://127.0.0.1:9",
apiKey: "k",
},
},
models: {
default: "local/m",
extract: "local/m",
},
},
};
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { validateWorkflowDescriptor } from "../src/workflow-descriptor.js";
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
describe("validateWorkflowDescriptor", () => {
// 1. Valid minimal descriptor

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