Compare commits

..

38 Commits

Author SHA1 Message Date
Scott Wei cc3f2b576c refactor(workflow): decouple agent context from CAS and fix monorepo checks
Move CAS access into extract dependencies so AgentContext stays state-only, and clean up type/lint/check regressions across CLI/dashboard to keep full check green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:30:07 +08:00
Scott Wei 884ff85205 refactor(workflow): remove dead extract retry export
Drop unused llmExtractWithRetry implementation and public exports.

Add solve-issue template coverage for tool_calls extraction path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:10:31 +08:00
Scott Wei a11cc62a81 refactor(workflow-runtime): use full ThreadContext in WorkflowFn
Redefine WorkflowFn to accept a complete ThreadContext plus WorkflowRuntime dependencies, removing ThreadInput and WorkflowFnOptions.

Move thread context construction into engine executeThread, update runtime loop/agent paths, and align templates/docs/tests with template-only definition exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:08:01 +08:00
Scott Wei 34f5e655d1 refactor(workflow): unify extraction behind ExtractFn
Route createExtract through reactExtract with plain-JSON correction retry.

Remove WorkflowFnOptions.llmProvider, ExtractMode, RoleDefinition.extractMode, ResolveRoleMetaFn.

Runtime createWorkflow calls options.extract directly; engine passes extract only.

Update templates, CLI skill docs, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:08:01 +08:00
xiaomo 44fb0694aa Merge pull request 'feat(serve+dashboard): write endpoints, SSE live, run dialog' (#129) from feat/118-serve-write-sse into main 2026-05-08 09:01:09 +00:00
xingyue cdcaff15ab feat(serve+dashboard): write endpoints, SSE live, run dialog
Serve API:
- POST /api/threads — run a new thread
- POST /api/threads/:id/kill — kill thread
- POST /api/threads/:id/pause — pause thread
- POST /api/threads/:id/resume — resume thread
- GET /api/threads/:id/live — SSE stream of thread records

Dashboard:
- Run Thread dialog (select workflow, enter prompt, set maxRounds)
- Thread detail controls (pause/resume/kill buttons)
- postJson API helper for write operations

262 tests pass. Refs: #118
2026-05-08 16:07:02 +08:00
xiaomo 402479ddef Merge pull request 'feat(dashboard): workflow dashboard' (#127) from feat/118-dashboard into main 2026-05-08 07:22:00 +00:00
xingyue a28dd3050e fix(dashboard): remove unused onBack prop from Sidebar 2026-05-08 15:17:40 +08:00
xingyue ce0d0a962c feat(dashboard): workflow dashboard 2026-05-08 14:48:43 +08:00
xiaomo 46b552ec01 Merge pull request 'refactor: split @uncaged/workflow-runtime from engine' (#126) from refactor/121-split-workflow-runtime into main 2026-05-08 06:42:15 +00:00
xiaoju 587518ac09 refactor(workflow): cleanup engine re-exports, final verification (Phase 4)
- Remove all re-exports from @uncaged/workflow -> @uncaged/workflow-runtime
- Fix cli-workflow imports to use @uncaged/workflow-runtime for types
- Update bundle-validator to allow @uncaged/workflow-runtime imports
- Update init templates to reference @uncaged/workflow-runtime
- 378 tests passing, build + check clean

Refs #121, relates #125
2026-05-08 06:37:56 +00:00
xiaoju e9e4960714 refactor(workflow): migrate downstream packages to workflow-runtime (Phase 2+3)
- Verify createWorkflow in runtime has zero I/O imports
- Migrate agent-cursor, agent-hermes to pure workflow-runtime dependency
- Migrate agent-llm, util-agent, templates to dual dependency
  (runtime for types, engine for CAS/merkle/buildDescriptor)
- All 377 tests passing

Refs #121, relates #123 #124
2026-05-08 06:33:52 +00:00
xiaoju 495c000356 refactor(workflow): split @uncaged/workflow-runtime from engine (Phase 1)
Create packages/workflow-runtime with the minimal runtime subset:
- Types (WorkflowFn, RoleOutput, AgentBinding, etc.)
- createWorkflow (pure orchestration, zero I/O)
- validateWorkflowDescriptor
- Result/ok/err, START/END constants

Zero external dependencies (zod as peer only).
Zero node:fs/node:path imports.

Engine (@uncaged/workflow) now depends on workflow-runtime and
provides CAS/merkle/extract implementations via injection.

Refs #121, relates #122
2026-05-08 06:29:49 +00:00
xiaomo 7e662f9287 Merge pull request 'feat(cli): add serve command — Hono HTTP API server' (#119) from feat/118-serve-api into main 2026-05-08 03:12:44 +00:00
xingyue 3ed38c65ec feat(cli): add serve command — Hono HTTP API server
Adds `uncaged-workflow serve` command that exposes workflow data
via a local HTTP API for the upcoming Web UI (RFC #118 Phase 1).

Routes:
- GET /healthz — health check
- GET /api/workflows — list registered workflows
- GET /api/workflows/:name — show workflow details
- GET /api/workflows/:name/history — version history
- GET /api/threads — list threads (optional ?workflow= filter)
- GET /api/threads/running — list running threads
- GET /api/threads/:id — show thread records (parsed JSONL)
- GET /api/cas — list CAS hashes
- GET /api/cas/:hash — get CAS content
- POST /api/cas — store content, returns hash
- DELETE /api/cas/:hash — remove CAS entry
- POST /api/cas/gc — garbage collect

Default: 127.0.0.1:7860, configurable via --port/-p and --host.

Refs: #118
2026-05-08 11:07:13 +08:00
xiaoju 38f2b0eeb2 Merge pull request 'chore: bump all packages to 0.2.0' (#117) from chore/bump-0.2.0 into main 2026-05-08 02:55:49 +00:00
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
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
203 changed files with 4227 additions and 1695 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
```
+2 -2
View File
@@ -48,7 +48,7 @@ uncaged-workflow run solve-issue --prompt "Fix bug #42"
## CLI Usage
```bash
uncaged-workflow help # Show all commands
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
@@ -56,7 +56,7 @@ uncaged-workflow thread show <id> # Inspect a thread
uncaged-workflow skill # Agent-consumable reference docs
```
See `uncaged-workflow help` for the full command reference.
Run `uncaged-workflow` with no arguments to print usage, or `uncaged-workflow skill cli` for the full CLI skill reference.
## Development
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"files": {
"includes": ["**", "!**/dist", "!**/node_modules"]
"includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": {
-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"
}
}
+2 -2
View File
@@ -2,10 +2,10 @@
"name": "@uncaged/workflow-monorepo",
"private": true,
"workspaces": [
"packages/*",
"examples"
"packages/*"
],
"scripts": {
"build": "bunx tsc --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-argv.js";
import type { ParsedAddArgv } from "../src/commands/workflow/index.js";
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
return { name, filePath, typesPath: null };
@@ -3,17 +3,23 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
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 {
createContentMerkleNode,
getGlobalCasDir,
getRegisteredWorkflow,
readWorkflowRegistry,
serializeMerkleNode,
} from "@uncaged/workflow";
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: {} };
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
describe("cli workflow commands", () => {
let prevEnv: string | undefined;
let storageRoot: string;
@@ -402,21 +412,23 @@ 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, "phase doc");
const raw = "phase doc";
const stored = casStoredForm(raw);
const put = await cmdCasPut(storageRoot, raw);
expect(put.ok).toBe(true);
if (!put.ok) {
return;
}
const hash = put.value;
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`);
expect(await readFile(blobPath, "utf8")).toBe("phase doc");
expect(await readFile(blobPath, "utf8")).toBe(stored);
const got = await cmdCasGet(storageRoot, hash);
expect(got.ok).toBe(true);
if (!got.ok) {
return;
}
expect(got.value).toBe("phase doc");
expect(got.value).toBe(stored);
const listed = await cmdCasList(storageRoot);
expect(listed.ok).toBe(true);
@@ -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", () => {
@@ -52,6 +51,7 @@ describe("init template", () => {
};
expect(pkg.type).toBe("module");
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(pkg.dependencies.zod).toBeDefined();
expect(pkg.name).toContain("review-pr");
@@ -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));
@@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test";
import { createContentMerkleNode, serializeMerkleNode } from "@uncaged/workflow";
import { createApp } from "../src/commands/serve/app.js";
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
function buildApp(storageRoot: string) {
const app = createApp(storageRoot);
return {
fetch: (path: string, init?: RequestInit) =>
app.fetch(new Request(`http://localhost${path}`, init)),
};
}
describe("serve /healthz", () => {
test("returns ok", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/healthz");
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean };
expect(body.ok).toBe(true);
});
});
describe("serve /api/workflows", () => {
test("returns empty list for missing storage", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/workflows");
// Registry file won't exist, should return error
expect(res.status).toBe(200);
});
});
describe("serve /api/threads", () => {
test("returns empty list for missing storage", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads");
expect(res.status).toBe(200);
const body = (await res.json()) as { threads: unknown[] };
expect(body.threads).toEqual([]);
});
test("returns 404 for missing thread", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads/nonexistent-id");
expect(res.status).toBe(404);
});
});
describe("serve /api/threads/running", () => {
test("returns empty list for missing storage", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads/running");
expect(res.status).toBe(200);
const body = (await res.json()) as { threads: unknown[] };
expect(body.threads).toEqual([]);
});
});
describe("serve /api/cas", () => {
test("returns empty list for missing storage", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/cas");
expect(res.status).toBe(200);
const body = (await res.json()) as { hashes: unknown[] };
expect(body.hashes).toEqual([]);
});
test("returns 404 for missing hash", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/cas/nonexistent-hash");
expect(res.status).toBe(404);
});
});
describe("serve CAS round-trip", () => {
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
test("put then get", async () => {
const { fetch } = buildApp(tmpDir);
const putRes = await fetch("/api/cas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "hello world" }),
});
expect(putRes.status).toBe(201);
const putBody = (await putRes.json()) as { hash: string };
expect(typeof putBody.hash).toBe("string");
const getRes = await fetch(`/api/cas/${putBody.hash}`);
expect(getRes.status).toBe(200);
const getBody = (await getRes.json()) as { content: string };
expect(getBody.content).toBe(casStoredForm("hello world"));
// cleanup
const delRes = await fetch(`/api/cas/${putBody.hash}`, { method: "DELETE" });
expect(delRes.status).toBe(200);
});
});
@@ -5,16 +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, cmdPause, cmdResume } from "../src/commands/thread/control.js";
import { cmdThreads } from "../src/commands/thread/list.js";
import { cmdPs } from "../src/commands/thread/ps.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";
`;
@@ -138,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 () => {
@@ -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");
}
+3 -1
View File
@@ -1,12 +1,14 @@
{
"name": "@uncaged/cli-workflow",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"bin": {
"uncaged-workflow": "src/cli.ts"
},
"dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow": "workspace:*",
"hono": "^4.12.18",
"yaml": "^2.8.4"
},
"scripts": {
+8 -61
View File
@@ -1,29 +1,12 @@
import type { CommandEntry, DispatchFn } from "./cli-command-types.js";
import { printCliError, printCliLine, printCliWarn } from "./cli-output.js";
import { printCliError, printCliLine } from "./cli-output.js";
import { getCommandRegistry } from "./cli-registry.js";
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher, dispatchGc } from "./commands/cas/dispatch.js";
import { createInitDispatcher } from "./commands/init/dispatch.js";
import {
createThreadDispatcher,
dispatchFork,
dispatchKill,
dispatchLive,
dispatchPause,
dispatchPs,
dispatchResume,
dispatchRun,
dispatchThreadList,
} from "./commands/thread/dispatch.js";
import {
createWorkflowDispatcher,
dispatchAdd,
dispatchHistory,
dispatchList,
dispatchRemove,
dispatchRollback,
dispatchShow,
} from "./commands/workflow/dispatch.js";
import { createCasDispatcher } from "./commands/cas/index.js";
import { createInitDispatcher } from "./commands/init/index.js";
import { dispatchServe } from "./commands/serve/index.js";
import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
import { createWorkflowDispatcher } from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
@@ -54,15 +37,11 @@ function dispatchGroup(
return entry.handler(storageRoot, argv.slice(1));
}
function printDeprecation(oldCmd: string, newCmd: string): void {
printCliWarn(`⚠ "${oldCmd}" is deprecated, use "${newCmd}" instead`);
}
export function formatCliUsage(): string {
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
}
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup, printDeprecation });
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
const dispatchThread = createThreadDispatcher({ dispatchGroup });
const dispatchCas = createCasDispatcher({ dispatchGroup });
const dispatchInit = createInitDispatcher({ dispatchGroup });
@@ -85,41 +64,15 @@ async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<numb
return showSkillDocOrIndex(argv[0]);
}
async function dispatchHelp(_storageRoot: string, argv: string[]): Promise<number> {
printCliWarn('⚠ "help" is deprecated, use "skill" instead');
const skillIdx = argv.indexOf("--skill");
if (skillIdx !== -1) {
return showSkillDocOrIndex(argv[skillIdx + 1]);
}
printCliLine(formatCliUsage());
return 0;
}
const COMMAND_TABLE: Record<string, DispatchFn> = {
workflow: dispatchWorkflow,
thread: dispatchThread,
cas: dispatchCas,
init: dispatchInit,
help: dispatchHelp,
skill: dispatchSkill,
run: dispatchRun,
live: dispatchLive,
};
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 },
serve: dispatchServe,
};
export async function runCli(storageRoot: string, argv: string[]): Promise<number> {
@@ -139,12 +92,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;
}
+4 -4
View File
@@ -1,9 +1,9 @@
import type { CommandGroup } from "./cli-command-types.js";
import { setCommandGroupsForUsage } from "./cli-usage-context.js";
import { CAS_SUBCOMMAND_TABLE } from "./commands/cas/dispatch.js";
import { INIT_SUBCOMMAND_TABLE } from "./commands/init/dispatch.js";
import { THREAD_SUBCOMMAND_TABLE } from "./commands/thread/dispatch.js";
import { WORKFLOW_SUBCOMMAND_TABLE } from "./commands/workflow/dispatch.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 [
+11
View File
@@ -57,6 +57,17 @@ export function formatCliUsage(
);
lines.push("");
lines.push("Server:");
lines.push(
...formatUsageCommandLines([
{
prefix: "serve [--port N] [--host ADDR]",
description: "Start HTTP API server (default: 127.0.0.1:7860)",
},
]),
);
lines.push("");
lines.push("Reference:");
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
lines.push(
@@ -1,4 +1,4 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
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";
@@ -7,6 +7,7 @@ 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);
@@ -110,7 +111,7 @@ export const CAS_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
gc: { handler: dispatchGc, args: "", description: "Garbage-collect unreferenced CAS entries" },
};
export function createCasDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
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);
@@ -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";
@@ -0,0 +1,5 @@
import type { DispatchGroupFn } from "../../cli-command-types.js";
export type CasDispatchDeps = {
dispatchGroup: DispatchGroupFn;
};
@@ -1,8 +1,9 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
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 {
@@ -52,7 +53,7 @@ export const INIT_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
},
};
export function createInitDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
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);
@@ -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";
@@ -12,23 +12,8 @@ import {
templateRolesTs,
templateTsconfigJson,
} from "./templates.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 type { CmdInitTemplateSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*");
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
type: "module",
dependencies: {
"@uncaged/workflow": "^0.1.0",
"@uncaged/workflow-runtime": "^0.1.0",
zod: "^4.0.0",
},
},
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
}
export function templateRolesTs(): string {
return `import type { RoleDefinition } from "@uncaged/workflow";
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4";
export const HELLO_TEMPLATE_DESCRIPTION =
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
}
export function templateModeratorTs(): string {
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
import type { HelloTemplateMeta } from "./roles.js";
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
}
export function templateIndexTs(): string {
return `import type { WorkflowDefinition } from "@uncaged/workflow";
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
import { helloTemplateModerator } from "./moderator.js";
import {
@@ -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** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范
@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createCasRoutes } from "./routes-cas.js";
import { createLiveRoutes } from "./routes-live.js";
import { createThreadRoutes } from "./routes-thread.js";
import { createWorkflowRoutes } from "./routes-workflow.js";
export function createApp(storageRoot: string): Hono {
const app = new Hono();
app.use("*", cors());
app.get("/healthz", (c) => c.json({ ok: true }));
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
app.route("/api/threads", createThreadRoutes(storageRoot));
app.route("/api/threads", createLiveRoutes(storageRoot));
app.route("/api/cas", createCasRoutes(storageRoot));
return app;
}
@@ -0,0 +1,3 @@
export { createApp } from "./app.js";
export { dispatchServe, startServer } from "./serve.js";
export type { ServeOptions } from "./types.js";
@@ -0,0 +1,56 @@
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
import { Hono } from "hono";
export function createCasRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hashes = await cas.list();
return c.json({ hashes });
});
app.get("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const content = await cas.get(c.req.param("hash"));
if (content === null) {
return c.json({ error: "not found" }, 404);
}
return c.json({ hash: c.req.param("hash"), content });
});
app.post("/", async (c) => {
const body = await c.req.json<{ content: string }>();
if (typeof body.content !== "string") {
return c.json({ error: "content field required" }, 400);
}
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = await cas.put(body.content);
return c.json({ hash }, 201);
});
app.delete("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = c.req.param("hash");
const content = await cas.get(hash);
if (content === null) {
return c.json({ error: "not found" }, 404);
}
await cas.delete(hash);
return c.json({ ok: true });
});
app.post("/gc", async (c) => {
const result = await garbageCollectCas(storageRoot);
if (!result.ok) {
return c.json({ error: result.error }, 500);
}
return c.json(result.value);
});
return app;
}
@@ -0,0 +1,176 @@
import { watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { resolveThreadDataPath } from "../../thread-scan.js";
type PumpState = {
contentOffset: number;
carry: string;
};
function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return { raw: line };
}
}
function isWorkflowResult(record: unknown): boolean {
return (
record !== null &&
typeof record === "object" &&
"type" in (record as Record<string, unknown>) &&
(record as Record<string, unknown>).type === "workflow-result"
);
}
function parseNewLines(text: string, state: PumpState): string[] {
if (text.length < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
}
const chunk = text.slice(state.contentOffset);
state.contentOffset = text.length;
state.carry += chunk;
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
const lines: string[] = [];
for (const line of parts) {
const trimmed = line.trim();
if (trimmed !== "") {
lines.push(trimmed);
}
}
return lines;
}
export function createLiveRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/:threadId/live", async (c) => {
const threadId = c.req.param("threadId");
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
if (dataPath === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const resolvedDataPath = dataPath;
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
return streamSSE(c, async (stream) => {
const dataState: PumpState = { contentOffset: 0, carry: "" };
const infoState: PumpState = { contentOffset: 0, carry: "" };
let eventId = 0;
async function pumpData(): Promise<boolean> {
let text: string;
try {
text = await readFile(resolvedDataPath, "utf8");
} catch {
return false;
}
const lines = parseNewLines(text, dataState);
for (const line of lines) {
const record = parseJsonLine(line);
eventId++;
await stream.writeSSE({
event: "record",
data: JSON.stringify(record),
id: String(eventId),
});
if (isWorkflowResult(record)) {
return true;
}
}
return false;
}
async function pumpInfo(): Promise<void> {
let text: string;
try {
text = await readFile(infoPath, "utf8");
} catch {
return;
}
const lines = parseNewLines(text, infoState);
for (const line of lines) {
const record = parseJsonLine(line);
if (
typeof record === "object" &&
record !== null &&
"raw" in (record as Record<string, unknown>)
) {
continue;
}
eventId++;
await stream.writeSSE({
event: "info",
data: JSON.stringify(record),
id: String(eventId),
});
}
}
// Initial pump
const done = await pumpData();
await pumpInfo();
if (done) {
return;
}
// Watch for changes
const controller = new AbortController();
let completed = false;
const dataWatcher = watch(resolvedDataPath, async () => {
if (completed) return;
const finished = await pumpData();
if (finished) {
completed = true;
controller.abort();
}
});
let infoWatcher: ReturnType<typeof watch> | null = null;
try {
infoWatcher = watch(infoPath, async () => {
if (completed) return;
await pumpInfo();
});
} catch {
// info file may not exist
}
stream.onAbort(() => {
completed = true;
dataWatcher.close();
infoWatcher?.close();
});
// Keep stream alive until completion or client disconnect
await new Promise<void>((resolve) => {
if (completed) {
resolve();
return;
}
controller.signal.addEventListener("abort", () => resolve(), { once: true });
stream.onAbort(() => resolve());
});
dataWatcher.close();
infoWatcher?.close();
});
});
return app;
}
@@ -0,0 +1,98 @@
import { Hono } from "hono";
import { readTextFileIfExists } from "../../fs-utils.js";
import {
listHistoricalThreads,
listRunningThreads,
resolveThreadDataPath,
} from "../../thread-scan.js";
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
import { cmdRun } from "../thread/run.js";
export function createThreadRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const nameFilter = c.req.query("workflow") ?? null;
const rows = await listHistoricalThreads(storageRoot, nameFilter);
return c.json({ threads: rows });
});
app.get("/running", async (c) => {
const rows = await listRunningThreads(storageRoot);
return c.json({ threads: rows });
});
app.get("/:threadId", async (c) => {
const threadId = c.req.param("threadId");
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
if (dataPath === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const text = await readTextFileIfExists(dataPath);
if (text === null) {
return c.json({ error: `thread data missing: ${threadId}` }, 404);
}
const lines = text.trim().split("\n");
const records = lines.map((line) => {
try {
return JSON.parse(line) as unknown;
} catch {
return { raw: line };
}
});
return c.json({ threadId, records });
});
app.post("/", async (c) => {
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
const name = body.workflow;
const prompt = body.prompt;
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
if (typeof name !== "string" || typeof prompt !== "string") {
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
}
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ threadId: result.value.threadId }, 201);
});
app.post("/:threadId/kill", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/pause", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/resume", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
return app;
}
@@ -0,0 +1,55 @@
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
} from "@uncaged/workflow";
import { Hono } from "hono";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return c.json({ error: reg.error.message }, 500);
}
const names = listRegisteredWorkflowNames(reg.value);
const workflows = names.map((name) => {
const entry = reg.value.workflows[name];
return {
name,
hash: entry?.hash ?? null,
timestamp: entry?.timestamp ?? null,
};
});
return c.json({ workflows });
});
app.get("/:name", async (c) => {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return c.json({ error: reg.error.message }, 500);
}
const name = c.req.param("name");
const entry = getRegisteredWorkflow(reg.value, name);
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, ...entry });
});
app.get("/:name/history", async (c) => {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return c.json({ error: reg.error.message }, 500);
}
const name = c.req.param("name");
const entry = getRegisteredWorkflow(reg.value, name);
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, history: entry.history });
});
return app;
}
@@ -0,0 +1,69 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { serve } from "bun";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import type { ServeOptions } from "./types.js";
export function startServer(storageRoot: string, options: ServeOptions): void {
const app = createApp(storageRoot);
const server = serve({
fetch: app.fetch,
port: options.port,
hostname: options.hostname,
});
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
}
function parsePortValue(value: string | undefined): Result<number, string> {
if (value === undefined) {
return err("--port requires a value");
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
return err(`invalid port: ${value}`);
}
return ok(parsed);
}
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
let port = 7860;
let hostname = "127.0.0.1";
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--port" || arg === "-p") {
const portResult = parsePortValue(argv[i + 1]);
if (!portResult.ok) {
return portResult;
}
port = portResult.value;
i++;
} else if (arg === "--host") {
const next = argv[i + 1];
if (next === undefined) {
return err("--host requires a value");
}
hostname = next;
i++;
}
}
return ok({ port, hostname });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseServeArgv(argv);
if (!parsed.ok) {
printCliLine(`error: ${parsed.error}`);
return 1;
}
startServer(storageRoot, parsed.value);
// Keep process alive
await new Promise(() => {});
return 0;
}
@@ -0,0 +1,4 @@
export type ServeOptions = {
port: number;
hostname: string;
};
@@ -1,4 +1,4 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
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";
@@ -13,6 +13,7 @@ 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);
@@ -191,7 +192,7 @@ export const THREAD_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
resume: { handler: dispatchResume, args: "<thread-id>", description: "Resume a paused thread" },
};
export function createThreadDispatcher(deps: { dispatchGroup: DispatchGroupFn }) {
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);
@@ -1,8 +1,8 @@
import { err, ok, type Result } from "@uncaged/workflow";
export function parseForkArgv(
argv: string[],
): Result<{ threadId: string; fromRole: string | null }, string> {
import type { ParsedForkArgv } from "./types.js";
export function parseForkArgv(argv: string[]): Result<ParsedForkArgv, string> {
if (argv.length === 0) {
return err("fork requires <thread-id>");
}
@@ -1,8 +1,21 @@
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,
@@ -14,3 +27,4 @@ export { cmdPs } from "./ps.js";
export { cmdThreadRemove } from "./rm.js";
export { cmdRun } from "./run.js";
export { cmdThreadShow } from "./show.js";
export type { LiveRoleRow } from "./types.js";
@@ -9,24 +9,18 @@ import {
getGlobalCasDir,
tryParseRoleStepRecord,
tryParseWorkflowResultRecord,
type WorkflowCompletion,
} from "@uncaged/workflow";
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
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");
@@ -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;
};
@@ -1,11 +1,6 @@
import { err, ok, type Result } from "@uncaged/workflow";
export type ParsedAddArgv = {
name: string;
filePath: string;
/** Override path to `.d.ts` when adding a bundle. */
typesPath: string | null;
};
import type { ParsedAddArgv } from "./types.js";
type ParsedLongFlag = { advance: 2; kind: "types"; value: string };
@@ -17,12 +17,7 @@ import {
import { storeWorkflowBundleArtifacts } from "../../bundle-store.js";
import { validateCliWorkflowName } from "../../workflow-name.js";
import type { ParsedAddArgv } from "./add-argv.js";
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");
@@ -1,4 +1,4 @@
import type { CommandEntry, DispatchGroupFn } from "../../cli-command-types.js";
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";
@@ -9,6 +9,7 @@ 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);
@@ -140,13 +141,8 @@ export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
},
};
type WorkflowDispatchDeps = {
dispatchGroup: DispatchGroupFn;
printDeprecation: (oldCmd: string, newCmd: string) => void;
};
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
const { dispatchGroup, printDeprecation } = deps;
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) {
@@ -154,7 +150,6 @@ export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
}
const sub = argv[0];
if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1));
}
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
@@ -1,9 +1,18 @@
export type { CmdAddSuccess } from "./add.js";
export { cmdAdd, formatAddSuccess } from "./add.js";
export type { ParsedAddArgv } from "./add-argv.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;
};
-8
View File
@@ -203,7 +203,6 @@ Each role has:
| \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`extractMode\` | "single" | Extraction mode |
## Development Workflow
@@ -229,10 +228,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();
}
+1 -1
View File
@@ -17,6 +17,6 @@
"rootDir": "src",
"types": ["bun-types"]
},
"references": [{ "path": "../workflow" }],
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
"include": ["src/**/*.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
# @uncaged/workflow-dashboard
Web dashboard for the Uncaged Workflow engine. Connects to the local
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
## Development
```bash
# Start the local API server (in another terminal)
uncaged-workflow serve
# Start the dashboard dev server
bun run dev
```
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
## Build
```bash
bun run build
```
Output goes to `dist/` — static files ready for CF Pages or any host.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.11"
}
}
+84
View File
@@ -0,0 +1,84 @@
const BASE = "/api";
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
throw new Error(err.error || `API ${res.status}`);
}
return res.json() as Promise<T>;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
};
export type ThreadSummary = {
threadId: string;
workflow: string | null;
hash: string | null;
startedAt: string | null;
status: string | null;
};
export type ThreadRecord = {
type: string;
role: string | null;
content: string | null;
timestamp: number | null;
[key: string]: unknown;
};
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson("/workflows");
}
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads");
}
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads/running");
}
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
}
export function runThread(
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson("/threads", { workflow, prompt, maxRounds });
}
export function killThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/kill`, {});
}
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/pause`, {});
}
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/resume`, {});
}
export function getHealth(): Promise<{ ok: boolean }> {
return fetchJson("/healthz");
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
type View = "threads" | "workflows";
export function App() {
const [view, setView] = useState<View>("threads");
const [selectedThread, setSelectedThread] = useState<string | null>(null);
const [showRun, setShowRun] = useState(false);
return (
<div className="flex h-screen">
<Sidebar view={view} onViewChange={setView} />
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{view === "threads" && !selectedThread && <ThreadList onSelect={setSelectedThread} />}
{view === "threads" && selectedThread && (
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} />
)}
{view === "workflows" && <WorkflowList />}
</div>
</main>
{showRun && (
<RunDialog
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setView("threads");
setSelectedThread(id);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,147 @@
import { useState } from "react";
import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(), []);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [maxRounds, setMaxRounds] = useState(10);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!workflow || !prompt) return;
setSubmitting(true);
setError(null);
try {
const result = await runThread(workflow, prompt, maxRounds);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Workflow
</label>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt
</label>
<textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..."
/>
</div>
<div>
<label
htmlFor="run-max-rounds"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Max Rounds
</label>
<input
id="run-max-rounds"
type="number"
value={maxRounds}
onChange={(e) => setMaxRounds(Number(e.target.value))}
min={1}
max={100}
className="w-24 px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
{submitting ? "Starting..." : "Run"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
type Props = {
view: "threads" | "workflows";
onViewChange: (v: "threads" | "workflows") => void;
};
export function Sidebar({ view, onViewChange }: Props) {
const items = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
return (
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div>
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
<button
type="button"
key={item.key}
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}}
>
{item.icon} {item.label}
</button>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,38 @@
import { getHealth } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onRun: () => void;
};
export function StatusBar({ onRun }: Props) {
const health = useFetch(() => getHealth(), []);
return (
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
<button
type="button"
onClick={onRun}
className="px-3 py-1 rounded text-xs font-medium"
style={{ background: "var(--color-accent)", color: "#fff" }}
>
Run Thread
</button>
</div>
<span>
{health.status === "loading" && "⏳ Connecting..."}
{health.status === "ok" && (
<span style={{ color: "var(--color-success)" }}> Connected</span>
)}
{health.status === "error" && (
<span style={{ color: "var(--color-error)" }}> Offline</span>
)}
</span>
</div>
);
}
@@ -0,0 +1,113 @@
import { useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ threadId, onBack }: Props) {
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
async function handleAction(action: "kill" | "pause" | "resume") {
setActionStatus(`${action}ing...`);
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(threadId);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
>
Back to threads
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
>
Pause
</button>
<button
type="button"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
>
Resume
</button>
<button
type="button"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
>
Kill
</button>
</div>
</div>
<h2 className="text-xl font-semibold mb-2 font-mono">{threadId}</h2>
{actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
{actionStatus}
</p>
)}
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{status === "ok" && (
<div className="space-y-3">
{data.records.map((r) => (
<div
key={`${r.type}:${r.role ?? ""}:${r.timestamp ?? 0}:${String(r.content ?? "")}`}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
>
{r.type}
</span>
{r.role && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{r.role}
</span>
)}
{r.timestamp && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{r.content && (
<pre
className="whitespace-pre-wrap text-xs mt-1"
style={{ color: "var(--color-text)" }}
>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,69 @@
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onSelect: (id: string) => void;
};
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const threads = data.threads;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Threads</h2>
{threads.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
) : (
<div className="space-y-2">
{threads.map((t) => (
<button
type="button"
key={t.threadId}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
{t.status && (
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "running"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-text-muted)",
color: "#000",
}}
>
{t.status}
</span>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.startedAt}
</p>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const workflows = data.workflows;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
{workflows.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
>
{w.currentHash}
</code>
</div>
))}
</div>
)}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
type FetchState<T> =
| { status: "loading"; data: null; error: null }
| { status: "ok"; data: T; error: null }
| { status: "error"; data: null; error: string };
export function useFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "loading",
data: null,
error: null,
});
useEffect(() => {
let cancelled = false;
setState({ status: "loading", data: null, error: null });
fetcher()
.then((data) => {
if (!cancelled) setState({ status: "ok", data, error: null });
})
.catch((err: unknown) => {
if (!cancelled)
setState({
status: "error",
data: null,
error: err instanceof Error ? err.message : String(err),
});
});
return () => {
cancelled = true;
};
// biome-ignore lint/correctness/useExhaustiveDependencies: this helper intentionally accepts caller-provided dependency arrays
}, deps);
return state;
}
+21
View File
@@ -0,0 +1,21 @@
@import "tailwindcss";
:root {
--color-bg: #0a0a0f;
--color-surface: #12121a;
--color-border: #1e1e2e;
--color-text: #e4e4ef;
--color-text-muted: #6b6b8a;
--color-accent: #7c6df0;
--color-accent-dim: #5a4db8;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./app.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:7860",
changeOrigin: true,
},
},
},
});
+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,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
import type * as z from "zod/v4";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-cursor",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -8,7 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0"
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentFn, ExtractContext } from "@uncaged/workflow";
import type { AgentFn, ExtractContext } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ExtractFn } from "@uncaged/workflow";
import type { ExtractFn } from "@uncaged/workflow-runtime";
export type CursorAgentConfig = {
model: string | null;
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { CursorAgentConfig } from "./types.js";
+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.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-hermes",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -8,7 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AgentFn } from "@uncaged/workflow";
import type { AgentFn } from "@uncaged/workflow-runtime";
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js";
@@ -1,4 +1,4 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { err, ok, type Result } from "@uncaged/workflow-runtime";
import type { HermesAgentConfig } from "./types.js";
+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,15 +1,9 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createLlmAdapter } from "../src/create-llm-adapter.js";
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-"));
const testCas = createCasStore(casDir);
function makeCtx(userContent: string): ThreadContext {
function makeCtx(userContent: string): AgentContext {
return {
start: {
role: START,
@@ -21,7 +15,6 @@ function makeCtx(userContent: string): ThreadContext {
steps: [],
threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" },
cas: testCas,
};
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@uncaged/workflow-agent-llm",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -8,6 +8,7 @@
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*"
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*"
}
}
@@ -5,7 +5,7 @@ import {
type LlmProvider,
ok,
type Result,
} from "@uncaged/workflow";
} from "@uncaged/workflow-runtime";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@uncaged/workflow-runtime",
"version": "0.2.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test"
},
"peerDependencies": {
"zod": "^4.0.0"
},
"devDependencies": {
"zod": "^4.0.0"
}
}
@@ -0,0 +1,2 @@
export type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
@@ -0,0 +1,13 @@
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
@@ -1,18 +1,6 @@
import { err, ok, type Result } from "./result.js";
import { err, ok, type Result } from "../util/index.js";
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
export type WorkflowRoleSchema = Record<string, unknown>;
export type WorkflowRoleDescriptor = {
description: string;
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
@@ -0,0 +1 @@
export type { CasStore } from "./types.js";
@@ -0,0 +1,6 @@
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
@@ -0,0 +1,170 @@
import type * as z from "zod/v4";
import type { CasStore } from "../cas/types.js";
import {
type AgentBinding,
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadContext,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowRuntime,
} from "../types.js";
import { mergeRefsWithContentHash } from "../util/index.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
return next !== END;
}
function resolveExtractedRefs(
roleDef: RoleDefinition<Record<string, unknown>>,
meta: unknown,
): string[] {
const extractRefsFn = roleDef.extractRefs;
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
return [];
}
return extractRefsFn(meta as Record<string, unknown>);
}
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
return store.put(raw);
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
const overrides = binding.overrides;
const overrideFn: AgentFn | undefined =
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
return overrideFn !== undefined ? overrideFn : binding.agent;
}
type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
params: {
thread: ModeratorContext<M>;
runtime: WorkflowRuntime;
},
): Promise<AdvanceOutcome<M>> {
const { thread, runtime } = params;
const modCtx: ModeratorContext<M> = thread;
const next = def.moderator(modCtx);
if (!isRoleNext(next)) {
return {
kind: "complete",
completion: { returnCode: 0, summary: "completed: moderator returned END" },
};
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
};
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const meta = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
);
const contentHash = await putContentBlob(runtime.cas, raw);
const refs = mergeRefsWithContentHash(
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
contentHash,
);
const step = {
role: next,
contentHash,
meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
return {
kind: "yield",
output: {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
},
step,
};
}
/**
* Binds pure role definitions + moderator to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)`.
*
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
* engine resolves from the workflow registry's `extract` scene.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
): WorkflowFn {
return async function* workflowLoop(
thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
if (thread.start.role !== START) {
throw new Error(`workflow loop expected start role to be ${START}`);
}
const maxRounds = thread.start.meta.maxRounds;
let currentThread = thread as ModeratorContext<M>;
while (true) {
if (currentThread.steps.length >= maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, {
thread: currentThread,
runtime,
});
if (outcome.kind === "complete") {
return outcome.completion;
}
yield outcome.output;
currentThread = {
...currentThread,
steps: [...currentThread.steps, outcome.step],
};
}
};
}
@@ -0,0 +1 @@
export { createWorkflow } from "./create-workflow.js";
@@ -0,0 +1 @@
export type { ExtractFn } from "./types.js";
@@ -0,0 +1,9 @@
import type * as z from "zod/v4";
import type { ExtractContext } from "../types.js";
export type ExtractFn = <T extends Record<string, unknown>>(
schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
+32
View File
@@ -0,0 +1,32 @@
export type {
WorkflowDescriptor,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
} from "./bundle/types.js";
export { validateWorkflowDescriptor } from "./bundle/workflow-descriptor.js";
export type { CasStore } from "./cas/index.js";
export { createWorkflow } from "./engine/index.js";
export type { ExtractFn } from "./extract/index.js";
export type {
AgentBinding,
AgentContext,
AgentFn,
ExtractContext,
LlmProvider,
Moderator,
ModeratorContext,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowFn,
WorkflowResult,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
export type { Result } from "./util/index.js";
export { err, ok } from "./util/index.js";
@@ -1,6 +1,7 @@
import type * as z from "zod/v4";
import type { CasStore } from "./cas.js";
import type { CasStore } from "./cas/index.js";
import type { ExtractFn } from "./extract/types.js";
/** Sentinel values for automaton control flow. */
export const START = "__start__" as const;
@@ -16,9 +17,6 @@ export type LlmProvider = {
model: string;
};
/** How the engine runs meta extraction for a role after the agent phase. */
export type ExtractMode = "single" | "react";
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
export type RoleOutput = {
role: string;
@@ -35,31 +33,23 @@ export type WorkflowCompletion = {
summary: string;
};
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
/** Final thread outcome from executeThread, including Merkle thread root CAS hash. */
export type WorkflowResult = WorkflowCompletion & {
rootHash: string;
};
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */
export type ThreadInput = {
prompt: string;
steps: RoleOutput[];
};
/** Options passed to a workflow bundle's `run` export (engine-provided). */
export type WorkflowFnOptions = {
threadId: string;
maxRounds: number;
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
depth: number;
/** Runtime dependencies passed to a workflow bundle's `run` export (engine-provided). */
export type WorkflowRuntime = {
/** Global CAS store for Merkle content blobs (role step bodies). */
cas: CasStore;
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
extract: ExtractFn;
};
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
export type WorkflowFn = (
input: ThreadInput,
options: WorkflowFnOptions,
thread: ThreadContext,
runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
/** Engine start frame: initial prompt + thread identity. */
@@ -81,22 +71,24 @@ export type RoleStep<M extends RoleMeta> = {
};
}[keyof M & string];
/** Phase 1: Moderator decides next role. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = {
/** Thread runtime context shared by moderator/agent/extractor phases. */
export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string;
/** Same as `WorkflowFnOptions.depth` for the active thread. */
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
depth: number;
start: StartStep;
steps: RoleStep<M>[];
};
/** Phase 1: Moderator decides next role. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
/** Phase 2: Agent executes — knows its role and prompt. */
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: {
name: string;
systemPrompt: string;
};
cas: CasStore;
};
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
@@ -104,16 +96,13 @@ export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string;
};
/** Alias — most external consumers see the agent-phase context. */
export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
export type AgentFn = (ctx: AgentContext) => Promise<string>;
/** Runtime agent assignment (optional per-role overrides). */
/** Runtime agent assignment (explicit null when no per-role overrides). */
export type AgentBinding = {
agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>;
overrides: Partial<Record<string, AgentFn>> | null;
};
/** Role wiring: prompts, schema, and human-readable description. */
@@ -124,7 +113,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
schema: z.ZodType<Meta>;
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
extractRefs: ((meta: Meta) => string[]) | null;
extractMode: ExtractMode;
};
/**
@@ -0,0 +1,3 @@
export { mergeRefsWithContentHash } from "./refs-field.js";
export { err, ok } from "./result.js";
export type { Result } from "./types.js";
@@ -0,0 +1,8 @@
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
const out = [...refs];
if (!out.includes(contentHash)) {
out.push(contentHash);
}
return out;
}
@@ -1,4 +1,4 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
@@ -0,0 +1 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"outDir": "dist",
"rootDir": "src",
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}

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