Compare commits

...

19 Commits

Author SHA1 Message Date
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
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
130 changed files with 2139 additions and 864 deletions
+1
View File
@@ -3,3 +3,4 @@ dist/
bun.lock bun.lock
*.tgz *.tgz
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
.npmrc
+2 -2
View File
@@ -48,7 +48,7 @@ uncaged-workflow run solve-issue --prompt "Fix bug #42"
## CLI Usage ## CLI Usage
```bash ```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 workflow list # List registered workflows
uncaged-workflow run <name> # Start a workflow thread uncaged-workflow run <name> # Start a workflow thread
uncaged-workflow thread list # List all threads 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 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 ## Development
-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", "name": "@uncaged/workflow-monorepo",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/*", "packages/*"
"examples"
], ],
"scripts": { "scripts": {
"build": "bunx tsc --build",
"check": "bunx tsc --build && biome check .", "check": "bunx tsc --build && biome check .",
"typecheck": "bunx tsc --build", "typecheck": "bunx tsc --build",
"format": "biome format --write .", "format": "biome format --write .",
@@ -3,7 +3,13 @@ import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promise
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { getGlobalCasDir, getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow"; import {
createContentMerkleNode,
getGlobalCasDir,
getRegisteredWorkflow,
readWorkflowRegistry,
serializeMerkleNode,
} from "@uncaged/workflow";
import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js"; import { cmdCasGet, cmdCasList, cmdCasPut, cmdCasRm } from "../src/commands/cas/index.js";
import { import {
cmdAdd, cmdAdd,
@@ -22,6 +28,10 @@ const fixtureDescriptor = `export const descriptor = { description: "fixture", r
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow"; const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`; `;
function casStoredForm(raw: string): string {
return serializeMerkleNode(createContentMerkleNode(raw));
}
describe("cli workflow commands", () => { describe("cli workflow commands", () => {
let prevEnv: string | undefined; let prevEnv: string | undefined;
let storageRoot: string; 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 () => { 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); expect(put.ok).toBe(true);
if (!put.ok) { if (!put.ok) {
return; return;
} }
const hash = put.value; const hash = put.value;
const blobPath = join(getGlobalCasDir(storageRoot), `${hash}.txt`); 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); const got = await cmdCasGet(storageRoot, hash);
expect(got.ok).toBe(true); expect(got.ok).toBe(true);
if (!got.ok) { if (!got.ok) {
return; return;
} }
expect(got.value).toBe("phase doc"); expect(got.value).toBe(stored);
const listed = await cmdCasList(storageRoot); const listed = await cmdCasList(storageRoot);
expect(listed.ok).toBe(true); expect(listed.ok).toBe(true);
@@ -7,6 +7,7 @@ import { cmdFork, cmdRun } from "../src/commands/thread/index.js";
import { cmdAdd } from "../src/commands/workflow/index.js"; import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists } from "../src/fs-utils.js"; import { pathExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
/** Three-role workflow that respects `input.steps` for fork/resume. */ /** Three-role workflow that respects `input.steps` for fork/resume. */
const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const threeRoleBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -77,6 +78,7 @@ describe("cli fork", () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-")); storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-fork-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot; process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await ensureTestWorkflowRegistryConfig(storageRoot);
}); });
afterEach(async () => { afterEach(async () => {
+10 -22
View File
@@ -1,21 +1,11 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { formatCliUsage, runCli } from "../src/cli-dispatch.js"; import { formatCliUsage, runCli } from "../src/cli-dispatch.js";
import { import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "../src/skill.js";
formatSkillDoc,
formatSkillIndex,
formatSkillTopic,
getSkillTopics,
} from "../src/skill.js";
const STORAGE_ROOT = "/tmp/help-test-storage"; const STORAGE_ROOT = "/tmp/help-test-storage";
describe("help command", () => { describe("runCli usage", () => {
test("help returns 0", async () => { test("no args prints usage and returns 1", async () => {
const code = await runCli(STORAGE_ROOT, ["help"]);
expect(code).toBe(0);
});
test("no args prints usage (not red) and returns 1", async () => {
const code = await runCli(STORAGE_ROOT, []); const code = await runCli(STORAGE_ROOT, []);
expect(code).toBe(1); 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", () => { describe("getSkillTopics", () => {
test("returns all topics", () => { test("returns all topics", () => {
const topics = getSkillTopics(); const topics = getSkillTopics();
@@ -128,8 +111,13 @@ describe("formatCliUsage", () => {
}); });
}); });
describe("formatSkillTopic('cli') — legacy formatSkillDoc", () => { const cliSkillDoc = formatSkillTopic("cli");
const doc = formatSkillDoc(); if (cliSkillDoc === null) {
throw new Error("BUG: cli skill topic missing");
}
describe("formatSkillTopic('cli')", () => {
const doc = cliSkillDoc;
test("contains title", () => { test("contains title", () => {
expect(doc).toContain("# uncaged-workflow CLI Reference"); expect(doc).toContain("# uncaged-workflow CLI Reference");
@@ -51,6 +51,7 @@ describe("init template", () => {
}; };
expect(pkg.type).toBe("module"); expect(pkg.type).toBe("module");
expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined(); expect(pkg.dependencies["@uncaged/workflow"]).toBeDefined();
expect(pkg.dependencies["@uncaged/workflow-runtime"]).toBeDefined();
expect(pkg.dependencies.zod).toBeDefined(); expect(pkg.dependencies.zod).toBeDefined();
expect(pkg.name).toContain("review-pr"); expect(pkg.name).toContain("review-pr");
@@ -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);
});
});
@@ -19,6 +19,7 @@ import {
import { cmdAdd } from "../src/commands/workflow/index.js"; import { cmdAdd } from "../src/commands/workflow/index.js";
import { pathExists, readTextFileIfExists } from "../src/fs-utils.js"; import { pathExists, readTextFileIfExists } from "../src/fs-utils.js";
import { addCliArgs } from "./bundle-fixture.js"; import { addCliArgs } from "./bundle-fixture.js";
import { ensureTestWorkflowRegistryConfig } from "./workflow-registry-fixture.js";
const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow"; const wfPutImport = `import { putContentMerkleNode } from "@uncaged/workflow";
`; `;
@@ -142,6 +143,7 @@ describe("cli thread commands", () => {
prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT; prevEnv = process.env.UNCAGED_WORKFLOW_STORAGE_ROOT;
storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-")); storageRoot = await mkdtemp(join(tmpdir(), "uncaged-wf-thread-"));
process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot; process.env.UNCAGED_WORKFLOW_STORAGE_ROOT = storageRoot;
await ensureTestWorkflowRegistryConfig(storageRoot);
}); });
afterEach(async () => { 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", "name": "@uncaged/cli-workflow",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"bin": { "bin": {
"uncaged-workflow": "src/cli.ts" "uncaged-workflow": "src/cli.ts"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"hono": "^4.12.18",
"yaml": "^2.8.4" "yaml": "^2.8.4"
}, },
"scripts": { "scripts": {
+7 -60
View File
@@ -1,29 +1,12 @@
import type { CommandEntry, DispatchFn } from "./cli-command-types.js"; 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 { getCommandRegistry } from "./cli-registry.js";
import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js"; import { formatCliUsage as formatCliUsageWithGroups } from "./cli-usage.js";
import { createCasDispatcher, dispatchGc } from "./commands/cas/index.js"; import { createCasDispatcher } from "./commands/cas/index.js";
import { createInitDispatcher } from "./commands/init/index.js"; import { createInitDispatcher } from "./commands/init/index.js";
import { import { dispatchServe } from "./commands/serve/index.js";
createThreadDispatcher, import { createThreadDispatcher, dispatchLive, dispatchRun } from "./commands/thread/index.js";
dispatchFork, import { createWorkflowDispatcher } from "./commands/workflow/index.js";
dispatchKill,
dispatchLive,
dispatchPause,
dispatchPs,
dispatchResume,
dispatchRun,
dispatchThreadList,
} from "./commands/thread/index.js";
import {
createWorkflowDispatcher,
dispatchAdd,
dispatchHistory,
dispatchList,
dispatchRemove,
dispatchRollback,
dispatchShow,
} from "./commands/workflow/index.js";
import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js"; import { formatSkillIndex, formatSkillTopic, getSkillTopics } from "./skill.js";
export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js"; export type { CommandEntry, CommandGroup, DispatchFn } from "./cli-command-types.js";
@@ -54,15 +37,11 @@ function dispatchGroup(
return entry.handler(storageRoot, argv.slice(1)); 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 { export function formatCliUsage(): string {
return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics()); return formatCliUsageWithGroups(getCommandRegistry(), getSkillTopics());
} }
const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup, printDeprecation }); const dispatchWorkflow = createWorkflowDispatcher({ dispatchGroup });
const dispatchThread = createThreadDispatcher({ dispatchGroup }); const dispatchThread = createThreadDispatcher({ dispatchGroup });
const dispatchCas = createCasDispatcher({ dispatchGroup }); const dispatchCas = createCasDispatcher({ dispatchGroup });
const dispatchInit = createInitDispatcher({ dispatchGroup }); const dispatchInit = createInitDispatcher({ dispatchGroup });
@@ -85,41 +64,15 @@ async function dispatchSkill(_storageRoot: string, argv: string[]): Promise<numb
return showSkillDocOrIndex(argv[0]); 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> = { const COMMAND_TABLE: Record<string, DispatchFn> = {
workflow: dispatchWorkflow, workflow: dispatchWorkflow,
thread: dispatchThread, thread: dispatchThread,
cas: dispatchCas, cas: dispatchCas,
init: dispatchInit, init: dispatchInit,
help: dispatchHelp,
skill: dispatchSkill, skill: dispatchSkill,
run: dispatchRun, run: dispatchRun,
live: dispatchLive, live: dispatchLive,
}; serve: dispatchServe,
const DEPRECATED_ALIASES: Record<string, { newCmd: string; handler: DispatchFn }> = {
add: { newCmd: "workflow add", handler: dispatchAdd },
list: { newCmd: "workflow list", handler: dispatchList },
show: { newCmd: "workflow show", handler: dispatchShow },
remove: { newCmd: "workflow rm", handler: dispatchRemove },
ps: { newCmd: "thread ps", handler: dispatchPs },
kill: { newCmd: "thread kill", handler: dispatchKill },
pause: { newCmd: "thread pause", handler: dispatchPause },
resume: { newCmd: "thread resume", handler: dispatchResume },
threads: { newCmd: "thread list", handler: dispatchThreadList },
fork: { newCmd: "thread fork", handler: dispatchFork },
gc: { newCmd: "cas gc", handler: dispatchGc },
history: { newCmd: "workflow history", handler: dispatchHistory },
rollback: { newCmd: "workflow rollback", handler: dispatchRollback },
}; };
export async function runCli(storageRoot: string, argv: string[]): Promise<number> { 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); 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}`); printCliError(`${formatCliUsage()}\n\nerror: unknown command ${command}`);
return 1; return 1;
} }
+11
View File
@@ -57,6 +57,17 @@ export function formatCliUsage(
); );
lines.push(""); 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:"); lines.push("Reference:");
const skillTopicNames = skillTopics.map((t) => t.name).join(", "); const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
lines.push( lines.push(
@@ -13,19 +13,7 @@ import {
templateTsconfigJson, templateTsconfigJson,
} from "./templates.js"; } from "./templates.js";
import type { CmdInitTemplateSuccess } from "./types.js"; import type { CmdInitTemplateSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
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);
}
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean { function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
return Array.isArray(workspaces) && workspaces.includes("templates/*"); return Array.isArray(workspaces) && workspaces.includes("templates/*");
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
type: "module", type: "module",
dependencies: { dependencies: {
"@uncaged/workflow": "^0.1.0", "@uncaged/workflow": "^0.1.0",
"@uncaged/workflow-runtime": "^0.1.0",
zod: "^4.0.0", zod: "^4.0.0",
}, },
}, },
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
} }
export function templateRolesTs(): 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"; import * as z from "zod/v4";
export const HELLO_TEMPLATE_DESCRIPTION = export const HELLO_TEMPLATE_DESCRIPTION =
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
} }
export function templateModeratorTs(): string { 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"; import type { HelloTemplateMeta } from "./roles.js";
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
} }
export function templateIndexTs(): string { 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 { helloTemplateModerator } from "./moderator.js";
import { import {
@@ -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);
}
@@ -5,19 +5,7 @@ import { err, ok, type Result } from "@uncaged/workflow";
import { pathExists } from "../../fs-utils.js"; import { pathExists } from "../../fs-utils.js";
import type { CmdInitWorkspaceSuccess } from "./types.js"; import type { CmdInitWorkspaceSuccess } from "./types.js";
import { validateWorkspaceSegment } from "./validate.js";
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);
}
function rootPackageJson(workspaceName: string): string { function rootPackageJson(workspaceName: string): string {
return `${JSON.stringify( return `${JSON.stringify(
@@ -119,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\` 2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\` 3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。 4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。 5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn****ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。 6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范 ## 4. 编码规范
@@ -0,0 +1,20 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createCasRoutes } from "./routes-cas.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/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,46 @@
import { Hono } from "hono";
import { readTextFileIfExists } from "../../fs-utils.js";
import {
listHistoricalThreads,
listRunningThreads,
resolveThreadDataPath,
} from "../../thread-scan.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 });
});
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;
};
@@ -9,8 +9,8 @@ import {
getGlobalCasDir, getGlobalCasDir,
tryParseRoleStepRecord, tryParseRoleStepRecord,
tryParseWorkflowResultRecord, tryParseWorkflowResultRecord,
type WorkflowCompletion,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js"; import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
import { printCliError, printCliLine } from "../../cli-output.js"; import { printCliError, printCliLine } from "../../cli-output.js";
@@ -142,7 +142,7 @@ export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
}; };
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) { export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
const { dispatchGroup, printDeprecation } = deps; const { dispatchGroup } = deps;
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> { return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv); const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
if (result !== null) { if (result !== null) {
@@ -150,7 +150,6 @@ export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
} }
const sub = argv[0]; const sub = argv[0];
if (sub === "remove") { if (sub === "remove") {
printDeprecation("workflow remove", "workflow rm");
return dispatchRemove(storageRoot, argv.slice(1)); return dispatchRemove(storageRoot, argv.slice(1));
} }
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`); printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
@@ -14,5 +14,4 @@ export type CmdAddSuccess = {
export type WorkflowDispatchDeps = { export type WorkflowDispatchDeps = {
dispatchGroup: DispatchGroupFn; dispatchGroup: DispatchGroupFn;
printDeprecation: (oldCmd: string, newCmd: string) => void;
}; };
-7
View File
@@ -229,10 +229,3 @@ uncaged-workflow live --latest
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions. 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", "rootDir": "src",
"types": ["bun-types"] "types": ["bun-types"]
}, },
"references": [{ "path": "../workflow" }], "references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"]
} }
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; 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 type * as z from "zod/v4";
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js"; import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow-agent-cursor", "name": "@uncaged/workflow-agent-cursor",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -8,7 +8,7 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "workspace:*", "@uncaged/workflow-util-agent": "workspace:*",
"zod": "^4.0.0" "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 { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import * as z from "zod/v4"; 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 = { export type CursorAgentConfig = {
model: string | null; 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"; import type { CursorAgentConfig } from "./types.js";
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow-agent-hermes", "name": "@uncaged/workflow-agent-hermes",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -8,7 +8,7 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow-runtime": "workspace:*",
"@uncaged/workflow-util-agent": "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 { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
import type { HermesAgentConfig } from "./types.js"; 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"; import type { HermesAgentConfig } from "./types.js";
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs"; import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow"; import { createCasStore } from "@uncaged/workflow";
import { START, type ThreadContext } from "@uncaged/workflow-runtime";
import { createLlmAdapter } from "../src/create-llm-adapter.js"; import { createLlmAdapter } from "../src/create-llm-adapter.js";
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow-agent-llm", "name": "@uncaged/workflow-agent-llm",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -8,6 +8,7 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*" "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*"
} }
} }
@@ -5,7 +5,7 @@ import {
type LlmProvider, type LlmProvider,
ok, ok,
type Result, type Result,
} from "@uncaged/workflow"; } from "@uncaged/workflow-runtime";
/** OpenAI chat completion message shape (passed to `/chat/completions`). */ /** OpenAI chat completion message shape (passed to `/chat/completions`). */
export type LlmMessage = { role: "system" | "user" | "assistant"; content: string }; 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>;
};
@@ -0,0 +1,40 @@
import { err, ok, type Result } from "../util/index.js";
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)) {
return err("descriptor must be a non-array object");
}
const root = value as Record<string, unknown>;
const description = root.description;
if (typeof description !== "string") {
return err("descriptor.description must be a string");
}
const rolesRaw = root.roles;
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
return err("descriptor.roles must be a non-array object");
}
const roles: Record<string, WorkflowRoleDescriptor> = {};
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
return err(`descriptor.roles.${roleName} must be a non-array object`);
}
const spec = specUnknown as Record<string, unknown>;
const roleDesc = spec.description;
if (typeof roleDesc !== "string") {
return err(`descriptor.roles.${roleName}.description must be a string`);
}
const schema = spec.schema;
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
}
roles[roleName] = {
description: roleDesc,
schema: schema as WorkflowRoleSchema,
};
}
return ok({ description, roles });
}
@@ -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,185 @@
import type { CasStore } from "../cas/types.js";
import {
type AgentBinding,
type AgentContext,
type AgentFn,
END,
type ExtractContext,
type ModeratorContext,
type ResolveRoleMetaFn,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadInput,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowFnOptions,
} 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,
resolveRoleMeta: ResolveRoleMetaFn<M>,
params: {
start: ModeratorContext<M>["start"];
steps: RoleStep<M>[];
options: WorkflowFnOptions;
},
): Promise<AdvanceOutcome<M>> {
const { start, steps, options } = params;
const modCtx: ModeratorContext<M> = {
threadId: options.threadId,
depth: options.depth,
start,
steps,
};
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 },
cas: options.cas,
};
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const meta = await resolveRoleMeta(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
extractCtx,
options,
);
const contentHash = await putContentBlob(options.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)` via `@uncaged/workflow-runtime`,
* which supplies {@link ResolveRoleMetaFn}.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
resolveRoleMeta: ResolveRoleMetaFn<M>,
): WorkflowFn {
return async function* workflowLoop(
input: ThreadInput,
options: WorkflowFnOptions,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
const nowMs = Date.now();
const start: ModeratorContext<M>["start"] = {
role: START,
content: input.prompt,
meta: { maxRounds: options.maxRounds },
timestamp: nowMs,
};
const baseTs = Date.now();
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
role: out.role,
contentHash: out.contentHash,
meta: out.meta,
refs: out.refs,
timestamp: baseTs + i,
})) as RoleStep<M>[];
while (true) {
if (steps.length >= options.maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${options.maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, resolveRoleMeta, {
start,
steps,
options,
});
if (outcome.kind === "complete") {
return outcome.completion;
}
yield outcome.output;
steps = [...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>;
+35
View File
@@ -0,0 +1,35 @@
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,
ExtractMode,
LlmProvider,
Moderator,
ModeratorContext,
ResolveRoleMetaFn,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
ThreadInput,
WorkflowCompletion,
WorkflowDefinition,
WorkflowFn,
WorkflowFnOptions,
WorkflowResult,
} 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 * as z from "zod/v4";
import type { CasStore } from "./cas/index.js"; import type { CasStore } from "./cas/index.js";
import type { ExtractFn } from "./extract/types.js";
/** Sentinel values for automaton control flow. */ /** Sentinel values for automaton control flow. */
export const START = "__start__" as const; export const START = "__start__" as const;
@@ -35,7 +36,7 @@ export type WorkflowCompletion = {
summary: string; 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 & { export type WorkflowResult = WorkflowCompletion & {
rootHash: string; rootHash: string;
}; };
@@ -54,6 +55,10 @@ export type WorkflowFnOptions = {
depth: number; depth: number;
/** Global CAS store for Merkle content blobs (role step bodies). */ /** Global CAS store for Merkle content blobs (role step bodies). */
cas: CasStore; cas: CasStore;
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
extract: ExtractFn;
/** Provider for `extractMode: "react"` roles; same backing config as `extract`. */
llmProvider: LlmProvider | null;
}; };
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */ /** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
@@ -110,10 +115,10 @@ export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */ /** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
export type AgentFn = (ctx: AgentContext) => Promise<string>; 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 = { export type AgentBinding = {
agent: AgentFn; agent: AgentFn;
overrides?: Partial<Record<string, AgentFn>>; overrides: Partial<Record<string, AgentFn>> | null;
}; };
/** Role wiring: prompts, schema, and human-readable description. */ /** Role wiring: prompts, schema, and human-readable description. */
@@ -143,3 +148,10 @@ export type WorkflowDefinition<M extends RoleMeta> = {
roles: { [K in keyof M & string]: RoleDefinition<M[K]> }; roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
moderator: Moderator<M>; moderator: Moderator<M>;
}; };
/** Engine-injected meta extraction for workflow loops (single + react modes). */
export type ResolveRoleMetaFn<M extends RoleMeta = RoleMeta> = (
roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>,
options: WorkflowFnOptions,
) => Promise<Record<string, unknown>>;
@@ -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;
}
@@ -0,0 +1,9 @@
import type { Result } from "./types.js";
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
@@ -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"]
}
@@ -5,7 +5,7 @@ import {
type RoleStep, type RoleStep,
START, START,
validateWorkflowDescriptor, validateWorkflowDescriptor,
} from "@uncaged/workflow"; } from "@uncaged/workflow-runtime";
import { buildDevelopDescriptor } from "../src/descriptor.js"; import { buildDevelopDescriptor } from "../src/descriptor.js";
import { developModerator } from "../src/index.js"; import { developModerator } from "../src/index.js";
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js"; import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow-template-develop", "name": "@uncaged/workflow-template-develop",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
} }
@@ -1,11 +1,5 @@
import { import { createWorkflow } from "@uncaged/workflow";
type AgentBinding, import type { AgentBinding, WorkflowDefinition, WorkflowFn } from "@uncaged/workflow-runtime";
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
import { developModerator } from "./moderator.js"; import { developModerator } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js"; import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
@@ -43,10 +37,6 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
moderator: developModerator, moderator: developModerator,
}; };
export function createDevelopRun( export function createDevelopRun(binding: AgentBinding): WorkflowFn {
binding: AgentBinding, return createWorkflow(developWorkflowDefinition, binding);
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
} }
@@ -1,5 +1,5 @@
import type { Moderator, ModeratorContext } from "@uncaged/workflow"; import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow"; import { END } from "@uncaged/workflow-runtime";
import type { DevelopMeta } from "./roles.js"; import type { DevelopMeta } from "./roles.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import { type CoderMeta, coderRole } from "./roles/coder.js"; import { type CoderMeta, coderRole } from "./roles/coder.js";
import { type CommitterMeta, committerRole } from "./roles/committer.js"; import { type CommitterMeta, committerRole } from "./roles/committer.js";
import { type PlannerMeta, plannerRole } from "./roles/planner.js"; import { type PlannerMeta, plannerRole } from "./roles/planner.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const coderMetaSchema = z.object({ export const coderMetaSchema = z.object({
@@ -15,7 +15,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
## Reading phase details ## Reading phase details
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`. Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`. The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const committerMetaSchema = z.discriminatedUnion("status", [ export const committerMetaSchema = z.discriminatedUnion("status", [
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const phaseSchema = z.object({ export const phaseSchema = z.object({
@@ -18,7 +18,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
## Storing phase details — MANDATORY ## Storing phase details — MANDATORY
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier. For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`. The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const reviewerMetaSchema = z.discriminatedUnion("status", [ export const reviewerMetaSchema = z.discriminatedUnion("status", [
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const testerMetaSchema = z.discriminatedUnion("status", [ export const testerMetaSchema = z.discriminatedUnion("status", [
@@ -2,15 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, createExtract } from "@uncaged/workflow";
import { import {
createCasStore,
createExtract,
END, END,
type ModeratorContext, type ModeratorContext,
type RoleStep, type RoleStep,
START, START,
validateWorkflowDescriptor, validateWorkflowDescriptor,
} from "@uncaged/workflow"; } from "@uncaged/workflow-runtime";
import { buildSolveIssueDescriptor } from "../src/descriptor.js"; import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import type { DeveloperMeta } from "../src/developer.js"; import type { DeveloperMeta } from "../src/developer.js";
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js"; import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
@@ -250,17 +249,20 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow. // Override developer so the test does not spin up a child workflow.
const run = createSolveIssueRun( const run = createSolveIssueRun({
{
agent: async () => "", agent: async () => "",
overrides: { developer: async () => "stub-root-hash" }, overrides: { developer: async () => "stub-root-hash" },
}, });
stubExtract,
stubLlmProvider,
);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, {
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
); );
const first = await gen.next(); const first = await gen.next();
expect(first.done).toBe(false); expect(first.done).toBe(false);
@@ -294,8 +296,7 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const calls: string[] = []; const calls: string[] = [];
const run = createSolveIssueRun( const run = createSolveIssueRun({
{
agent: async () => { agent: async () => {
calls.push("default"); calls.push("default");
return ""; return "";
@@ -314,13 +315,17 @@ describe("createSolveIssueRun", () => {
return ""; return "";
}, },
}, },
}, });
stubExtract,
stubLlmProvider,
);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, {
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
); );
await gen.next(); await gen.next();
expect(calls).toEqual(["preparer"]); expect(calls).toEqual(["preparer"]);
@@ -353,8 +358,7 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
let developerInvocations = 0; let developerInvocations = 0;
const run = createSolveIssueRun( const run = createSolveIssueRun({
{
agent: async () => "", agent: async () => "",
overrides: { overrides: {
developer: async () => { developer: async () => {
@@ -362,13 +366,17 @@ describe("createSolveIssueRun", () => {
return "stub-root-hash"; return "stub-root-hash";
}, },
}, },
}, });
stubExtract,
stubLlmProvider,
);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, {
threadId: "01TEST000000000000000000TR",
maxRounds: 20,
depth: 0,
cas,
extract: stubExtract,
llmProvider: stubLlmProvider,
},
); );
// preparer // preparer
await gen.next(); await gen.next();
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow-template-solve-issue", "name": "@uncaged/workflow-template-solve-issue",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*", "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*",
"zod": "^4.0.0" "zod": "^4.0.0"
} }
} }
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const developerMetaSchema = z.object({ export const developerMetaSchema = z.object({
@@ -1,12 +1,5 @@
import { import { createWorkflow, workflowAsAgent } from "@uncaged/workflow";
type AgentBinding, import type { AgentBinding, WorkflowDefinition, WorkflowFn } from "@uncaged/workflow-runtime";
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
workflowAsAgent,
} from "@uncaged/workflow";
import { solveIssueModerator } from "./moderator.js"; import { solveIssueModerator } from "./moderator.js";
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js"; import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
@@ -46,11 +39,7 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in * {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it. * `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/ */
export function createSolveIssueRun( export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop"); const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = { const mergedBinding: AgentBinding = {
agent: binding.agent, agent: binding.agent,
@@ -59,5 +48,5 @@ export function createSolveIssueRun(
developer: developerOverride, developer: developerOverride,
}, },
}; };
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider); return createWorkflow(solveIssueWorkflowDefinition, mergedBinding);
} }
@@ -1,5 +1,5 @@
import type { Moderator } from "@uncaged/workflow"; import type { Moderator } from "@uncaged/workflow-runtime";
import { END } from "@uncaged/workflow"; import { END } from "@uncaged/workflow-runtime";
import type { SolveIssueMeta } from "./roles.js"; import type { SolveIssueMeta } from "./roles.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import { type DeveloperMeta, developerRole } from "./developer.js"; import { type DeveloperMeta, developerRole } from "./developer.js";
import { type PreparerMeta, preparerRole } from "./roles/preparer.js"; import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js"; import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
const toolchainSchema = z.object({ const toolchainSchema = z.object({
@@ -1,4 +1,4 @@
import type { RoleDefinition } from "@uncaged/workflow"; import type { RoleDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
export const submitterMetaSchema = z.discriminatedUnion("status", [ export const submitterMetaSchema = z.discriminatedUnion("status", [
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow"; import { createCasStore, putContentMerkleNode } from "@uncaged/workflow";
import { START, type ThreadContext } from "@uncaged/workflow-runtime";
import { buildAgentPrompt } from "../src/index.js"; import { buildAgentPrompt } from "../src/index.js";
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow-util-agent", "name": "@uncaged/workflow-util-agent",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -14,6 +14,7 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow": "workspace:*" "@uncaged/workflow": "workspace:*",
"@uncaged/workflow-runtime": "workspace:*"
} }
} }
@@ -1,5 +1,5 @@
import type { AgentContext } from "@uncaged/workflow";
import { getContentMerklePayload } from "@uncaged/workflow"; import { getContentMerklePayload } from "@uncaged/workflow";
import type { AgentContext } from "@uncaged/workflow-runtime";
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> { async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
const text = await getContentMerklePayload(ctx.cas, contentHash); const text = await getContentMerklePayload(ctx.cas, contentHash);
@@ -1,6 +1,6 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { err, ok, type Result } from "@uncaged/workflow"; import { err, ok, type Result } from "@uncaged/workflow-runtime";
export type SpawnCliError = export type SpawnCliError =
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string } | { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
+1 -1
View File
@@ -27,7 +27,7 @@ import { createWorkflow, readWorkflowRegistry, executeThread } from "@uncaged/wo
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` | | **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` | | **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers | | **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
| **CAS** | `createCasStore`, `createThreadCas`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` | | **CAS** | `createCasStore`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` | | **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
| **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` | | **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` |
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role | | **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
@@ -1,9 +1,8 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { buildDescriptor } from "../src/bundle/build-descriptor.js"; import { buildDescriptor } from "../src/bundle/build-descriptor.js";
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js"; import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
import { END } from "../src/types.js";
describe("buildDescriptor", () => { describe("buildDescriptor", () => {
test("produces a descriptor that validates and includes JSON schemas per role", () => { test("produces a descriptor that validates and includes JSON schemas per role", () => {
@@ -39,6 +39,16 @@ export const run = async function* (_input, options) {
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
}); });
test("allows static import of @uncaged/workflow-runtime", () => {
const source = `${minimalDescriptor}import { createWorkflow } from "@uncaged/workflow-runtime";
import { putContentMerkleNode } from "@uncaged/workflow";
export const run = createWorkflow({ description: "x", roles: {}, moderator: () => "END" }, {});
`;
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
expect(r.ok).toBe(true);
});
test("rejects wrong filename suffix", () => { test("rejects wrong filename suffix", () => {
const r = validateWorkflowBundle({ const r = validateWorkflowBundle({
filePath: "/tmp/w.js", filePath: "/tmp/w.js",
+21 -15
View File
@@ -3,14 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { createCasStore, createThreadCas } from "../src/cas/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { hashString } from "../src/cas/hash.js"; import { hashString } from "../src/cas/hash.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
describe("cas module exports", () => { function casStoredForm(raw: string): string {
test("createThreadCas is a deprecated alias of createCasStore", () => { return serializeMerkleNode(createContentMerkleNode(raw));
expect(createThreadCas).toBe(createCasStore); }
});
});
describe("createCasStore", () => { describe("createCasStore", () => {
let casDir: string; let casDir: string;
@@ -25,25 +24,30 @@ describe("createCasStore", () => {
test("put returns consistent hash for same content", async () => { test("put returns consistent hash for same content", async () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const h1 = await cas.put("hello world"); const raw = "hello world";
const h2 = await cas.put("hello world"); const stored = casStoredForm(raw);
const h1 = await cas.put(raw);
const h2 = await cas.put(raw);
expect(h1).toBe(h2); expect(h1).toBe(h2);
expect(h1).toBe(hashString(stored));
expect(h1).toHaveLength(13); expect(h1).toHaveLength(13);
}); });
test("put returns hash matching hashString", async () => { test("put returns hash matching hashString of merkle-stored form", async () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const content = "some content to store"; const content = "some content to store";
const stored = casStoredForm(content);
const h = await cas.put(content); const h = await cas.put(content);
expect(h).toBe(hashString(content)); expect(h).toBe(hashString(stored));
}); });
test("get returns stored content", async () => { test("get returns merkle-serialized blob for raw puts", async () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const content = "line1\nline2\nline3"; const content = "line1\nline2\nline3";
const stored = casStoredForm(content);
const h = await cas.put(content); const h = await cas.put(content);
const retrieved = await cas.get(h); const retrieved = await cas.get(h);
expect(retrieved).toBe(content); expect(retrieved).toBe(stored);
}); });
test("get returns null for missing hash", async () => { test("get returns null for missing hash", async () => {
@@ -82,11 +86,13 @@ describe("createCasStore", () => {
test("put is idempotent — same content written twice causes no error", async () => { test("put is idempotent — same content written twice causes no error", async () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const h1 = await cas.put("idempotent"); const raw = "idempotent";
const h2 = await cas.put("idempotent"); const stored = casStoredForm(raw);
const h1 = await cas.put(raw);
const h2 = await cas.put(raw);
expect(h1).toBe(h2); expect(h1).toBe(h2);
const content = await cas.get(h1); const content = await cas.get(h1);
expect(content).toBe("idempotent"); expect(content).toBe(stored);
}); });
test("different content produces different hashes", async () => { test("different content produces different hashes", async () => {
+216 -17
View File
@@ -1,9 +1,9 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { import {
createContentMerkleNode, createContentMerkleNode,
@@ -13,8 +13,6 @@ import {
} from "../src/cas/merkle.js"; } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { END, type LlmProvider } from "../src/types.js";
import { createLogger } from "../src/util/logger.js"; import { createLogger } from "../src/util/logger.js";
const plannerMetaSchema = z.object({ const plannerMetaSchema = z.object({
@@ -82,11 +80,112 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
}; };
} }
const demoExtract = createExtract({ const EXTRACT_REGISTRY_YAML = `config:
baseUrl: "http://127.0.0.1:9", maxDepth: 3
apiKey: "test", providers:
model: "test", stub:
}); baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
}
const SUPERVISOR_INTERVAL_REGISTRY_YAML = `config:
maxDepth: 3
supervisorInterval: 2
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
extract: stub/model
supervisor: stub/supervisor-cheap
workflows: {}
`;
const SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML = `config:
maxDepth: 3
supervisorInterval: 10
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
extract: stub/model
supervisor: stub/supervisor-cheap
workflows: {}
`;
async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<void> {
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
}
/** Extract rounds use tool_calls; supervisor uses plain `content` (no tools). */
function installMockExtractThenSupervisor(params: {
extractArgs: ReadonlyArray<Record<string, unknown>>;
supervisorContent: string;
onSupervisorCall?: () => void;
}): () => void {
const origFetch = globalThis.fetch;
let extractI = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
init?: RequestInit,
): Promise<Response> => {
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const hasTools = Array.isArray(tools) && tools.length > 0;
if (hasTools) {
const args =
params.extractArgs[extractI] ?? params.extractArgs[params.extractArgs.length - 1];
if (args === undefined) {
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
}
extractI += 1;
const firstTool = tools[0] as Record<string, unknown>;
const fn = firstTool.function as Record<string, unknown> | undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
params.onSupervisorCall?.();
return new Response(
JSON.stringify({
choices: [{ message: { content: params.supervisorContent } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
const demoWorkflow = createWorkflow<DemoMeta>( const demoWorkflow = createWorkflow<DemoMeta>(
{ {
@@ -125,8 +224,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
coder: async () => "code-body", coder: async () => "code-body",
}, },
}, },
demoExtract,
null,
); );
describe("executeThread", () => { describe("executeThread", () => {
@@ -150,6 +247,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -166,6 +264,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -258,6 +357,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body"))); const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
@@ -295,6 +395,7 @@ describe("executeThread", () => {
timestamp: histTs, timestamp: histTs,
}, },
], ],
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -354,6 +455,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -391,6 +493,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -407,6 +510,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -549,9 +653,6 @@ describe("executeThread", () => {
{ preconnect: origFetch.preconnect.bind(origFetch) }, { preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch; ) as typeof fetch;
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
const extractFn = createExtract(llm);
const dagWorkflow = createWorkflow<DagDemoMeta>( const dagWorkflow = createWorkflow<DagDemoMeta>(
{ {
roles: { roles: {
@@ -567,9 +668,7 @@ describe("executeThread", () => {
}, },
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
}, },
{ agent: async () => dagRootHash }, { agent: async () => dagRootHash, overrides: null },
extractFn,
llm,
); );
const threadId = "01KQXKW18CT8G75T53R8F4G7YG"; const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -577,6 +676,7 @@ describe("executeThread", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeExtractRegistryConfig(root);
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController(); const ac = new AbortController();
@@ -592,6 +692,7 @@ describe("executeThread", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -613,4 +714,102 @@ describe("executeThread", () => {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("supervisor stops thread when interval elapses and model returns stop", async () => {
restoreFetch = installMockExtractThenSupervisor({
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
supervisorContent: "stop",
});
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-stop-"));
try {
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
const hash = "C9NMV6V2TQT81";
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeRegistryYaml(root, SUPERVISOR_INTERVAL_REGISTRY_YAML);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
const result = await executeThread(
demoWorkflow,
"demo-flow",
{ prompt: "supervisor-stop-case", steps: [] },
{
maxRounds: 20,
depth: 0,
signal: ac.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
);
expect(result.returnCode).toBe(0);
expect(result.summary).toBe("completed: supervisor stopped thread");
const dataText = await readFile(dataPath, "utf8");
const lines = dataText
.trim()
.split("\n")
.filter((l) => l !== "");
expect(lines.length).toBe(3);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("supervisor is not invoked before supervisorInterval rounds", async () => {
let supervisorCalls = 0;
restoreFetch = installMockExtractThenSupervisor({
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
supervisorContent: "stop",
onSupervisorCall: () => {
supervisorCalls += 1;
},
});
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-skip-"));
try {
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
const hash = "C9NMV6V2TQT81";
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
await writeRegistryYaml(root, SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML);
const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
const result = await executeThread(
demoWorkflow,
"demo-flow",
{ prompt: "no-supervisor-yet", steps: [] },
{
maxRounds: 20,
depth: 0,
signal: ac.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
storageRoot: root,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
);
expect(supervisorCalls).toBe(0);
expect(result.returnCode).toBe(0);
expect(result.summary).toBe("completed: moderator returned END");
} finally {
await rm(root, { recursive: true, force: true });
}
});
}); });
@@ -1,87 +0,0 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getExtractProvider } from "../src/extract-provider.js";
describe("getExtractProvider", () => {
test("returns provider when config.extract is present", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: literal-key
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
expect(r.value.model).toBe("qwen-plus");
expect(r.value.apiKey).toBe("literal-key");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("errs when registry has no config section", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
const r = await getExtractProvider(root);
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no global config");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("resolves apiKey from env at registry read time", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.apiKey).toBe("resolved-secret");
} finally {
if (prev === undefined) {
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
} else {
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
}
await rm(root, { recursive: true, force: true });
}
});
});
@@ -2,12 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { LlmProvider } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js"; import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { reactExtract } from "../src/extract/react-extract.js"; import { reactExtract } from "../src/extract/react-extract.js";
import type { LlmProvider } from "../src/types.js";
const metaSchema = z.object({ seen: z.string() }); const metaSchema = z.object({ seen: z.string() });
@@ -1,15 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test"; import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { createWorkflow } from "../src/engine/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js"; import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js"; import { createLogger } from "../src/util/logger.js";
const phaseSchema = z.object({ const phaseSchema = z.object({
@@ -76,11 +74,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
}; };
} }
const refsDemoExtract = createExtract({ const EXTRACT_REGISTRY_YAML = `config:
baseUrl: "http://127.0.0.1:9", maxDepth: 3
apiKey: "test", providers:
model: "test", stub:
}); baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>( const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
{ {
@@ -98,9 +101,8 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
}, },
{ {
agent: async () => "plan-output", agent: async () => "plan-output",
overrides: null,
}, },
refsDemoExtract,
null,
); );
describe("RoleStep refs tracking", () => { describe("RoleStep refs tracking", () => {
@@ -142,6 +144,7 @@ describe("RoleStep refs tracking", () => {
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`); const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`); const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true }); await mkdir(join(root, "logs", hash), { recursive: true });
await writeFile(join(root, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
const cas = createCasStore(join(root, "cas")); const cas = createCasStore(join(root, "cas"));
const logger = createLogger({ sink: { kind: "file", path: infoPath } }); const logger = createLogger({ sink: { kind: "file", path: infoPath } });
@@ -158,6 +161,7 @@ describe("RoleStep refs tracking", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
+78 -10
View File
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
const yaml = ` const yaml = `
config: config:
maxDepth: 3 maxDepth: 3
extract: providers:
dashscope:
baseUrl: https://example.com/v1 baseUrl: https://example.com/v1
model: qwen-plus
apiKey: secret-key apiKey: secret-key
models:
default: dashscope/qwen-turbo
extract: dashscope/qwen-plus
workflows: workflows:
solve-issue: solve-issue:
hash: SPVR4BDMSGC1W hash: SPVR4BDMSGC1W
@@ -125,9 +128,69 @@ workflows:
return; return;
} }
expect(r.value.config.maxDepth).toBe(3); expect(r.value.config.maxDepth).toBe(3);
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1"); expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.extract.model).toBe("qwen-plus"); expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
expect(r.value.config.extract.apiKey).toBe("secret-key"); expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
expect(r.value.config.supervisorInterval).toBe(3);
});
test("defaults supervisorInterval to 3 when omitted", () => {
const yaml = `
config:
maxDepth: 0
providers:
p:
baseUrl: https://example.com
apiKey: k
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(true);
if (!r.ok || r.value.config === null) {
return;
}
expect(r.value.config.supervisorInterval).toBe(3);
});
test("parses explicit supervisorInterval", () => {
const yaml = `
config:
maxDepth: 0
supervisorInterval: 7
providers:
p:
baseUrl: https://example.com
apiKey: k
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(true);
if (!r.ok || r.value.config === null) {
return;
}
expect(r.value.config.supervisorInterval).toBe(7);
});
test("parse errors when supervisorInterval is negative", () => {
const yaml = `
config:
maxDepth: 0
supervisorInterval: -1
providers:
p:
baseUrl: https://example.com
apiKey: k
models:
default: p/m
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(false);
}); });
test("parses config apiKey env: prefix from process.env", () => { test("parses config apiKey env: prefix from process.env", () => {
@@ -137,10 +200,13 @@ workflows:
const yaml = ` const yaml = `
config: config:
maxDepth: 1 maxDepth: 1
extract: providers:
dashscope:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: env:WF_REGISTRY_TEST_API_KEY apiKey: env:WF_REGISTRY_TEST_API_KEY
models:
default: dashscope/qwen-plus
extract: dashscope/qwen-plus
workflows: {} workflows: {}
`; `;
const r = parseWorkflowRegistryYaml(yaml); const r = parseWorkflowRegistryYaml(yaml);
@@ -148,7 +214,7 @@ workflows: {}
if (!r.ok) { if (!r.ok) {
return; return;
} }
expect(r.value.config?.extract.apiKey).toBe("from-env"); expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
} finally { } finally {
if (prev === undefined) { if (prev === undefined) {
delete process.env.WF_REGISTRY_TEST_API_KEY; delete process.env.WF_REGISTRY_TEST_API_KEY;
@@ -165,10 +231,12 @@ workflows: {}
const yaml = ` const yaml = `
config: config:
maxDepth: 1 maxDepth: 1
extract: providers:
p:
baseUrl: https://example.com baseUrl: https://example.com
model: m
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
models:
default: p/m
workflows: {} workflows: {}
`; `;
const r = parseWorkflowRegistryYaml(yaml); const r = parseWorkflowRegistryYaml(yaml);
@@ -0,0 +1,104 @@
import { describe, expect, test } from "bun:test";
import { resolveModel } from "../src/config/resolve-model.js";
import type { WorkflowConfig } from "../src/registry/index.js";
function sampleConfig(): WorkflowConfig {
return {
maxDepth: 3,
supervisorInterval: 3,
providers: {
dashscope: {
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiKey: "secret",
},
other: {
baseUrl: "https://other.example/v1",
apiKey: "k2",
},
},
models: {
default: "dashscope/qwen-plus",
extract: "other/foo/bar-model",
},
};
}
describe("resolveModel", () => {
test("uses explicit scene mapping", () => {
const config = sampleConfig();
const r = resolveModel(config, "extract");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://other.example/v1");
expect(r.value.apiKey).toBe("k2");
expect(r.value.model).toBe("foo/bar-model");
});
test("falls back to models.default when scene is missing", () => {
const config = sampleConfig();
const r = resolveModel(config, "unknown-scene");
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.model).toBe("qwen-plus");
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
});
test("errs when scene missing and no default", () => {
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
extract: "p/m",
},
};
const r = resolveModel(config, "other");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no model mapping");
expect(r.error).toContain("default");
});
test("errs when provider is unknown", () => {
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "missing/m",
},
};
const r = resolveModel(config, "any");
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("unknown provider");
});
test("errs on invalid model reference shape", () => {
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
p: { baseUrl: "https://x", apiKey: "k" },
},
models: {
default: "no-slash-model",
},
};
const r = resolveModel(config, "x");
expect(r.ok).toBe(false);
});
});
@@ -0,0 +1,136 @@
import { afterEach, describe, expect, test } from "bun:test";
import { parseSupervisorDecisionText, runSupervisor } from "../src/engine/supervisor.js";
import type { WorkflowConfig } from "../src/registry/index.js";
import type { LogFn } from "../src/util/index.js";
const noopLogger: LogFn = () => {};
function supervisorOnlyConfig(): WorkflowConfig {
return {
maxDepth: 3,
supervisorInterval: 3,
providers: {
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
},
models: {
extract: "stub/extract-model",
supervisor: "stub/supervisor-model",
},
};
}
describe("parseSupervisorDecisionText", () => {
test("reads continue and stop case-insensitively", () => {
expect(parseSupervisorDecisionText("continue")).toBe("continue");
expect(parseSupervisorDecisionText("CONTINUE")).toBe("continue");
expect(parseSupervisorDecisionText("stop")).toBe("stop");
expect(parseSupervisorDecisionText("STOP.")).toBe("stop");
});
test("finds token inside a sentence", () => {
expect(parseSupervisorDecisionText("Answer: continue")).toBe("continue");
expect(parseSupervisorDecisionText("I recommend stop now")).toBe("stop");
});
test("when both appear, earlier token wins", () => {
expect(parseSupervisorDecisionText("continue then stop")).toBe("continue");
expect(parseSupervisorDecisionText("stop then continue")).toBe("stop");
});
test("defaults to continue when unclear", () => {
expect(parseSupervisorDecisionText("maybe later")).toBe("continue");
});
});
describe("runSupervisor", () => {
let restoreFetch: (() => void) | null = null;
afterEach(() => {
restoreFetch?.();
restoreFetch = null;
});
test("returns continue when supervisor model cannot be resolved (no fetch)", async () => {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async () => {
throw new Error("fetch should not run when supervisor is not configured");
},
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const config: WorkflowConfig = {
maxDepth: 1,
supervisorInterval: 3,
providers: {
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
},
models: {
extract: "stub/m",
},
};
const r = await runSupervisor({
config,
prompt: "task",
recentSteps: [{ role: "planner", summary: "{}" }],
logger: noopLogger,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value).toBe("continue");
});
test("returns stop from chat/completions assistant content", async () => {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async () =>
new Response(
JSON.stringify({
choices: [{ message: { content: "stop" } }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const r = await runSupervisor({
config: supervisorOnlyConfig(),
prompt: "do X",
recentSteps: [{ role: "a", summary: "{}" }],
logger: noopLogger,
});
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value).toBe("stop");
});
test("returns err on invalid JSON body", async () => {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(async () => new Response("not-json", { status: 200 }), {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
const r = await runSupervisor({
config: supervisorOnlyConfig(),
prompt: "p",
recentSteps: [],
logger: noopLogger,
});
expect(r.ok).toBe(false);
});
});
@@ -9,6 +9,17 @@ import { createCasStore } from "../src/cas/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js"; import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js"; import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
const WORKER_REGISTRY_YAML = `config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/model
workflows: {}
`;
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
export const descriptor = { export const descriptor = {
@@ -89,6 +100,7 @@ describe("worker process", () => {
try { try {
const hash = "C9NMV6V2TQT81"; const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true }); await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`); const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8"); await writeFile(bundlePath, bundleSource, "utf8");
@@ -136,6 +148,7 @@ describe("worker process", () => {
try { try {
const hash = "C9NMV6V2TQT81"; const hash = "C9NMV6V2TQT81";
await mkdir(join(root, "bundles"), { recursive: true }); await mkdir(join(root, "bundles"), { recursive: true });
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
const bundlePath = join(root, "bundles", `${hash}.esm.js`); const bundlePath = join(root, "bundles", `${hash}.esm.js`);
await writeFile(bundlePath, bundleSource, "utf8"); await writeFile(bundlePath, bundleSource, "utf8");
@@ -2,20 +2,18 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { END } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
import { createCasStore } from "../src/cas/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js"; import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js"; import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
import { createWorkflow } from "../src/engine/create-workflow.js"; import { createWorkflow } from "../src/engine/create-workflow.js";
import { executeThread } from "../src/engine/engine.js"; import { executeThread } from "../src/engine/engine.js";
import { createExtract } from "../src/extract/extract-fn.js";
import { import {
readWorkflowRegistry, readWorkflowRegistry,
registerWorkflowVersion, registerWorkflowVersion,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "../src/registry/registry.js"; } from "../src/registry/registry.js";
import { END } from "../src/types.js";
import { createLogger } from "../src/util/logger.js"; import { createLogger } from "../src/util/logger.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js"; import { workflowAsAgent } from "../src/workflow-as-agent.js";
@@ -76,11 +74,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
}; };
} }
const parentExtract = createExtract({ const PARENT_REGISTRY_WITH_CONFIG = `config:
baseUrl: "http://127.0.0.1:9", maxDepth: 3
apiKey: "test", providers:
model: "test", stub:
}); baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`;
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow"; const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
@@ -131,6 +134,8 @@ describe("workflowAsAgent integration", () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-")); const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
try { try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
const { hash: childHash } = await installChildWorkflow(root); const { hash: childHash } = await installChildWorkflow(root);
const parentWorkflow = createWorkflow<ParentMeta>( const parentWorkflow = createWorkflow<ParentMeta>(
@@ -147,9 +152,7 @@ describe("workflowAsAgent integration", () => {
}, },
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
}, },
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) }, { agent: workflowAsAgent("child-wf", { storageRoot: root }), overrides: null },
parentExtract,
null,
); );
const threadId = "01KQXKW18CT8G75T53R8F4G7YG"; const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -173,6 +176,7 @@ describe("workflowAsAgent integration", () => {
awaitAfterEachYield: async () => {}, awaitAfterEachYield: async () => {},
forkSourceThreadId: null, forkSourceThreadId: null,
prefilledDiskSteps: null, prefilledDiskSteps: null,
storageRoot: root,
}, },
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas }, { threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger, logger,
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { createCasStore } from "../src/cas/cas.js"; import { createCasStore } from "../src/cas/cas.js";
import { hashWorkflowBundleBytes } from "../src/cas/hash.js"; import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
import { parseMerkleNode } from "../src/cas/merkle.js"; import { parseMerkleNode } from "../src/cas/merkle.js";
@@ -11,7 +11,6 @@ import {
registerWorkflowVersion, registerWorkflowVersion,
writeWorkflowRegistry, writeWorkflowRegistry,
} from "../src/registry/registry.js"; } from "../src/registry/registry.js";
import { type AgentContext, START } from "../src/types.js";
import { workflowAsAgent } from "../src/workflow-as-agent.js"; import { workflowAsAgent } from "../src/workflow-as-agent.js";
function makeAgentCtx(params: { function makeAgentCtx(params: {
@@ -93,6 +92,21 @@ describe("workflowAsAgent", () => {
test("runs registered workflow and returns child thread root CAS hash", async () => { test("runs registered workflow and returns child thread root CAS hash", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-")); const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
try { try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
providers:
stub:
baseUrl: http://127.0.0.1:9
apiKey: test
models:
default: stub/m
workflows: {}
`,
"utf8",
);
await installChildWorkflow(root); await installChildWorkflow(root);
const agent = workflowAsAgent("child-wf", { storageRoot: root }); const agent = workflowAsAgent("child-wf", { storageRoot: root });
const out = await agent( const out = await agent(
@@ -140,12 +154,18 @@ describe("workflowAsAgent", () => {
...reg.value, ...reg.value,
config: { config: {
maxDepth: 2, maxDepth: 2,
extract: { supervisorInterval: 3,
providers: {
local: {
baseUrl: "http://127.0.0.1:9", baseUrl: "http://127.0.0.1:9",
model: "m",
apiKey: "k", apiKey: "k",
}, },
}, },
models: {
default: "local/m",
extract: "local/m",
},
},
}; };
const wr = await writeWorkflowRegistry(root, withCfg); const wr = await writeWorkflowRegistry(root, withCfg);
expect(wr.ok).toBe(true); expect(wr.ok).toBe(true);
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/workflow", "name": "@uncaged/workflow",
"version": "0.1.0", "version": "0.2.0",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/index.ts", "types": "src/index.ts",
@@ -8,6 +8,7 @@
"test": "bun test" "test": "bun test"
}, },
"dependencies": { "dependencies": {
"@uncaged/workflow-runtime": "workspace:*",
"acorn": "^8.16.0", "acorn": "^8.16.0",
"xxhashjs": "^0.2.2", "xxhashjs": "^0.2.2",
"yaml": "^2.8.4" "yaml": "^2.8.4"
@@ -1,6 +1,5 @@
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime";
import * as z from "zod/v4"; import * as z from "zod/v4";
import type { RoleMeta, WorkflowDefinition } from "../types.js";
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js"; import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema { function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
@@ -38,7 +38,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) { if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
return false; return false;
} }
if (spec === "@uncaged/workflow") { if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") {
return true; return true;
} }
return isBuiltin(spec); return isBuiltin(spec);
@@ -1,4 +1,4 @@
import type { WorkflowFn } from "../types.js"; import type { WorkflowFn } from "@uncaged/workflow-runtime";
import { err, ok, type Result } from "../util/index.js"; import { err, ok, type Result } from "../util/index.js";
import { importWorkflowBundleModule } from "./bundle-import-env.js"; import { importWorkflowBundleModule } from "./bundle-import-env.js";
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js"; import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
+6 -14
View File
@@ -1,18 +1,10 @@
import type { WorkflowFn } from "../types.js"; import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-runtime";
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */ export type {
export type WorkflowRoleSchema = Record<string, unknown>; WorkflowDescriptor,
WorkflowRoleDescriptor,
export type WorkflowRoleDescriptor = { WorkflowRoleSchema,
description: string; } from "@uncaged/workflow-runtime";
schema: WorkflowRoleSchema;
};
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
export type WorkflowDescriptor = {
description: string;
roles: Record<string, WorkflowRoleDescriptor>;
};
export type WorkflowBundleValidationInput = { export type WorkflowBundleValidationInput = {
/** Absolute or relative path (used for `.esm.js` suffix checks). */ /** Absolute or relative path (used for `.esm.js` suffix checks). */

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