Compare commits

..

33 Commits

Author SHA1 Message Date
Scott Wei 5b60fa6454 refactor(workflow-runtime): flatten package layout and centralize types
Collapse bundle/cas/extract/util stubs into types.ts; move createWorkflow and Result helpers to src root.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 23:03:53 +08:00
Scott Wei 8ff6f7e778 refactor(workflow): move descriptor validation out of runtime
Keep @uncaged/workflow-runtime focused on bundle runtime capabilities by relocating descriptor validation implementation to @uncaged/workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 17:45:15 +08:00
xiaoju e04e75bdee chore: remove stale self-referencing symlink
小橘 🍊(NEKO Team)
2026-05-08 09:35:32 +00:00
xiaoju c65c29c1b5 Merge pull request 'refactor(workflow): simplify extraction + thread runtime contract' (#132) from refactor/thread-context-runtime into main 2026-05-08 09:34:26 +00:00
Scott Wei cc3f2b576c refactor(workflow): decouple agent context from CAS and fix monorepo checks
Move CAS access into extract dependencies so AgentContext stays state-only, and clean up type/lint/check regressions across CLI/dashboard to keep full check green.

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

Add solve-issue template coverage for tool_calls extraction path.

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

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

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

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

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

Update templates, CLI skill docs, and tests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs #110
2026-05-08 02:21:45 +00:00
xiaomo 83649fd836 Merge pull request 'docs: add README.md to all 8 packages' (#112) from docs/package-readmes into main 2026-05-08 02:19:25 +00:00
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
141 changed files with 3167 additions and 1235 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
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"files": { "files": {
"includes": ["**", "!**/dist", "!**/node_modules"] "includes": ["**", "!**/dist", "!**/node_modules", "!packages/workflow/workflow"]
}, },
"assist": { "actions": { "source": { "organizeImports": "on" } } }, "assist": { "actions": { "source": { "organizeImports": "on" } } },
"formatter": { "formatter": {
-53
View File
@@ -1,53 +0,0 @@
import { createExtract, createWorkflow, END, type RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
type Roles = {
greeter: { greeting: string };
};
const greeterMetaSchema = z.object({
greeting: z.string(),
});
export const descriptor = {
description: "A simple hello world workflow",
roles: {
greeter: {
description: "Generates a greeting",
schema: {
type: "object",
properties: { greeting: { type: "string" } },
required: ["greeting"],
},
},
},
};
const greeter: RoleDefinition<Roles["greeter"]> = {
description: "Generates a greeting",
systemPrompt: "You greet the user briefly.",
extractPrompt: "Extract the greeting string produced for the user.",
schema: greeterMetaSchema,
extractRefs: null,
extractMode: "single",
};
const extract = createExtract({
baseUrl: "http://127.0.0.1:9",
apiKey: "",
model: "",
});
export const run = createWorkflow<Roles>(
{
roles: { greeter },
moderator(ctx) {
return ctx.steps.length === 0 ? "greeter" : END;
},
},
{
agent: async (ctx) => `Hello, ${ctx.start.content}`,
},
extract,
null,
);
-9
View File
@@ -1,9 +0,0 @@
{
"name": "@uncaged/workflow-examples",
"private": true,
"type": "module",
"dependencies": {
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
+2 -2
View File
@@ -2,10 +2,10 @@
"name": "@uncaged/workflow-monorepo", "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** 注入 \`WorkflowRuntime\`
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。 6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
## 4. 编码规范 ## 4. 编码规范
@@ -0,0 +1,22 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { createCasRoutes } from "./routes-cas.js";
import { createLiveRoutes } from "./routes-live.js";
import { createThreadRoutes } from "./routes-thread.js";
import { createWorkflowRoutes } from "./routes-workflow.js";
export function createApp(storageRoot: string): Hono {
const app = new Hono();
app.use("*", cors());
app.get("/healthz", (c) => c.json({ ok: true }));
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
app.route("/api/threads", createThreadRoutes(storageRoot));
app.route("/api/threads", createLiveRoutes(storageRoot));
app.route("/api/cas", createCasRoutes(storageRoot));
return app;
}
@@ -0,0 +1,3 @@
export { createApp } from "./app.js";
export { dispatchServe, startServer } from "./serve.js";
export type { ServeOptions } from "./types.js";
@@ -0,0 +1,56 @@
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
import { Hono } from "hono";
export function createCasRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hashes = await cas.list();
return c.json({ hashes });
});
app.get("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const content = await cas.get(c.req.param("hash"));
if (content === null) {
return c.json({ error: "not found" }, 404);
}
return c.json({ hash: c.req.param("hash"), content });
});
app.post("/", async (c) => {
const body = await c.req.json<{ content: string }>();
if (typeof body.content !== "string") {
return c.json({ error: "content field required" }, 400);
}
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = await cas.put(body.content);
return c.json({ hash }, 201);
});
app.delete("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = c.req.param("hash");
const content = await cas.get(hash);
if (content === null) {
return c.json({ error: "not found" }, 404);
}
await cas.delete(hash);
return c.json({ ok: true });
});
app.post("/gc", async (c) => {
const result = await garbageCollectCas(storageRoot);
if (!result.ok) {
return c.json({ error: result.error }, 500);
}
return c.json(result.value);
});
return app;
}
@@ -0,0 +1,176 @@
import { watch } from "node:fs";
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { resolveThreadDataPath } from "../../thread-scan.js";
type PumpState = {
contentOffset: number;
carry: string;
};
function parseJsonLine(line: string): unknown {
try {
return JSON.parse(line) as unknown;
} catch {
return { raw: line };
}
}
function isWorkflowResult(record: unknown): boolean {
return (
record !== null &&
typeof record === "object" &&
"type" in (record as Record<string, unknown>) &&
(record as Record<string, unknown>).type === "workflow-result"
);
}
function parseNewLines(text: string, state: PumpState): string[] {
if (text.length < state.contentOffset) {
state.contentOffset = 0;
state.carry = "";
}
const chunk = text.slice(state.contentOffset);
state.contentOffset = text.length;
state.carry += chunk;
const parts = state.carry.split("\n");
state.carry = parts.pop() ?? "";
const lines: string[] = [];
for (const line of parts) {
const trimmed = line.trim();
if (trimmed !== "") {
lines.push(trimmed);
}
}
return lines;
}
export function createLiveRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/:threadId/live", async (c) => {
const threadId = c.req.param("threadId");
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
if (dataPath === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const resolvedDataPath = dataPath;
const infoPath = join(dirname(resolvedDataPath), `${threadId}.info.jsonl`);
return streamSSE(c, async (stream) => {
const dataState: PumpState = { contentOffset: 0, carry: "" };
const infoState: PumpState = { contentOffset: 0, carry: "" };
let eventId = 0;
async function pumpData(): Promise<boolean> {
let text: string;
try {
text = await readFile(resolvedDataPath, "utf8");
} catch {
return false;
}
const lines = parseNewLines(text, dataState);
for (const line of lines) {
const record = parseJsonLine(line);
eventId++;
await stream.writeSSE({
event: "record",
data: JSON.stringify(record),
id: String(eventId),
});
if (isWorkflowResult(record)) {
return true;
}
}
return false;
}
async function pumpInfo(): Promise<void> {
let text: string;
try {
text = await readFile(infoPath, "utf8");
} catch {
return;
}
const lines = parseNewLines(text, infoState);
for (const line of lines) {
const record = parseJsonLine(line);
if (
typeof record === "object" &&
record !== null &&
"raw" in (record as Record<string, unknown>)
) {
continue;
}
eventId++;
await stream.writeSSE({
event: "info",
data: JSON.stringify(record),
id: String(eventId),
});
}
}
// Initial pump
const done = await pumpData();
await pumpInfo();
if (done) {
return;
}
// Watch for changes
const controller = new AbortController();
let completed = false;
const dataWatcher = watch(resolvedDataPath, async () => {
if (completed) return;
const finished = await pumpData();
if (finished) {
completed = true;
controller.abort();
}
});
let infoWatcher: ReturnType<typeof watch> | null = null;
try {
infoWatcher = watch(infoPath, async () => {
if (completed) return;
await pumpInfo();
});
} catch {
// info file may not exist
}
stream.onAbort(() => {
completed = true;
dataWatcher.close();
infoWatcher?.close();
});
// Keep stream alive until completion or client disconnect
await new Promise<void>((resolve) => {
if (completed) {
resolve();
return;
}
controller.signal.addEventListener("abort", () => resolve(), { once: true });
stream.onAbort(() => resolve());
});
dataWatcher.close();
infoWatcher?.close();
});
});
return app;
}
@@ -0,0 +1,98 @@
import { Hono } from "hono";
import { readTextFileIfExists } from "../../fs-utils.js";
import {
listHistoricalThreads,
listRunningThreads,
resolveThreadDataPath,
} from "../../thread-scan.js";
import { cmdKill, cmdPause, cmdResume } from "../thread/control.js";
import { cmdRun } from "../thread/run.js";
export function createThreadRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const nameFilter = c.req.query("workflow") ?? null;
const rows = await listHistoricalThreads(storageRoot, nameFilter);
return c.json({ threads: rows });
});
app.get("/running", async (c) => {
const rows = await listRunningThreads(storageRoot);
return c.json({ threads: rows });
});
app.get("/:threadId", async (c) => {
const threadId = c.req.param("threadId");
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
if (dataPath === null) {
return c.json({ error: `thread not found: ${threadId}` }, 404);
}
const text = await readTextFileIfExists(dataPath);
if (text === null) {
return c.json({ error: `thread data missing: ${threadId}` }, 404);
}
const lines = text.trim().split("\n");
const records = lines.map((line) => {
try {
return JSON.parse(line) as unknown;
} catch {
return { raw: line };
}
});
return c.json({ threadId, records });
});
app.post("/", async (c) => {
let body: Record<string, unknown>;
try {
body = (await c.req.json()) as Record<string, unknown>;
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
const name = body.workflow;
const prompt = body.prompt;
const maxRounds = typeof body.maxRounds === "number" ? body.maxRounds : 10;
if (typeof name !== "string" || typeof prompt !== "string") {
return c.json({ error: "workflow (string) and prompt (string) are required" }, 400);
}
const result = await cmdRun(storageRoot, name, prompt, maxRounds);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ threadId: result.value.threadId }, 201);
});
app.post("/:threadId/kill", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdKill(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/pause", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdPause(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
app.post("/:threadId/resume", async (c) => {
const threadId = c.req.param("threadId");
const result = await cmdResume(storageRoot, threadId);
if (!result.ok) {
return c.json({ error: result.error }, 400);
}
return c.json({ ok: true });
});
return app;
}
@@ -0,0 +1,55 @@
import {
getRegisteredWorkflow,
listRegisteredWorkflowNames,
readWorkflowRegistry,
} from "@uncaged/workflow";
import { Hono } from "hono";
export function createWorkflowRoutes(storageRoot: string): Hono {
const app = new Hono();
app.get("/", async (c) => {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return c.json({ error: reg.error.message }, 500);
}
const names = listRegisteredWorkflowNames(reg.value);
const workflows = names.map((name) => {
const entry = reg.value.workflows[name];
return {
name,
hash: entry?.hash ?? null,
timestamp: entry?.timestamp ?? null,
};
});
return c.json({ workflows });
});
app.get("/:name", async (c) => {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return c.json({ error: reg.error.message }, 500);
}
const name = c.req.param("name");
const entry = getRegisteredWorkflow(reg.value, name);
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, ...entry });
});
app.get("/:name/history", async (c) => {
const reg = await readWorkflowRegistry(storageRoot);
if (!reg.ok) {
return c.json({ error: reg.error.message }, 500);
}
const name = c.req.param("name");
const entry = getRegisteredWorkflow(reg.value, name);
if (entry === null) {
return c.json({ error: `workflow not found: ${name}` }, 404);
}
return c.json({ name, history: entry.history });
});
return app;
}
@@ -0,0 +1,69 @@
import { err, ok, type Result } from "@uncaged/workflow";
import { serve } from "bun";
import { printCliLine } from "../../cli-output.js";
import { createApp } from "./app.js";
import type { ServeOptions } from "./types.js";
export function startServer(storageRoot: string, options: ServeOptions): void {
const app = createApp(storageRoot);
const server = serve({
fetch: app.fetch,
port: options.port,
hostname: options.hostname,
});
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
}
function parsePortValue(value: string | undefined): Result<number, string> {
if (value === undefined) {
return err("--port requires a value");
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
return err(`invalid port: ${value}`);
}
return ok(parsed);
}
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
let port = 7860;
let hostname = "127.0.0.1";
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--port" || arg === "-p") {
const portResult = parsePortValue(argv[i + 1]);
if (!portResult.ok) {
return portResult;
}
port = portResult.value;
i++;
} else if (arg === "--host") {
const next = argv[i + 1];
if (next === undefined) {
return err("--host requires a value");
}
hostname = next;
i++;
}
}
return ok({ port, hostname });
}
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
const parsed = parseServeArgv(argv);
if (!parsed.ok) {
printCliLine(`error: ${parsed.error}`);
return 1;
}
startServer(storageRoot, parsed.value);
// Keep process alive
await new Promise(() => {});
return 0;
}
@@ -0,0 +1,4 @@
export type ServeOptions = {
port: number;
hostname: string;
};
@@ -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;
}; };
-8
View File
@@ -203,7 +203,6 @@ Each role has:
| \`extractPrompt\` | string | Instruction for extracting structured meta | | \`extractPrompt\` | string | Instruction for extracting structured meta |
| \`schema\` | ZodSchema | Validates the extracted meta | | \`schema\` | ZodSchema | Validates the extracted meta |
| \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking | | \`extractRefs\` | fn or null | Extracts CAS hashes from meta for DAG linking |
| \`extractMode\` | "single" | Extraction mode |
## Development Workflow ## Development Workflow
@@ -229,10 +228,3 @@ uncaged-workflow live --latest
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions. 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"]
} }
+24
View File
@@ -0,0 +1,24 @@
# @uncaged/workflow-dashboard
Web dashboard for the Uncaged Workflow engine. Connects to the local
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
## Development
```bash
# Start the local API server (in another terminal)
uncaged-workflow serve
# Start the dashboard dev server
bun run dev
```
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
## Build
```bash
bun run build
```
Output goes to `dist/` — static files ready for CF Pages or any host.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.11"
}
}
+84
View File
@@ -0,0 +1,84 @@
const BASE = "/api";
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
throw new Error(err.error || `API ${res.status}`);
}
return res.json() as Promise<T>;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
};
export type ThreadSummary = {
threadId: string;
workflow: string | null;
hash: string | null;
startedAt: string | null;
status: string | null;
};
export type ThreadRecord = {
type: string;
role: string | null;
content: string | null;
timestamp: number | null;
[key: string]: unknown;
};
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson("/workflows");
}
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads");
}
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads/running");
}
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
}
export function runThread(
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson("/threads", { workflow, prompt, maxRounds });
}
export function killThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/kill`, {});
}
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/pause`, {});
}
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/resume`, {});
}
export function getHealth(): Promise<{ ok: boolean }> {
return fetchJson("/healthz");
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from "react";
import { RunDialog } from "./components/run-dialog.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { StatusBar } from "./components/status-bar.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
type View = "threads" | "workflows";
export function App() {
const [view, setView] = useState<View>("threads");
const [selectedThread, setSelectedThread] = useState<string | null>(null);
const [showRun, setShowRun] = useState(false);
return (
<div className="flex h-screen">
<Sidebar view={view} onViewChange={setView} />
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} />
<div className="flex-1 overflow-auto p-6">
{view === "threads" && !selectedThread && <ThreadList onSelect={setSelectedThread} />}
{view === "threads" && selectedThread && (
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} />
)}
{view === "workflows" && <WorkflowList />}
</div>
</main>
{showRun && (
<RunDialog
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
setView("threads");
setSelectedThread(id);
}}
/>
)}
</div>
);
}
@@ -0,0 +1,147 @@
import { useState } from "react";
import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(), []);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [maxRounds, setMaxRounds] = useState(10);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!workflow || !prompt) return;
setSubmitting(true);
setError(null);
try {
const result = await runThread(workflow, prompt, maxRounds);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setSubmitting(false);
}
}
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ background: "rgba(0,0,0,0.6)" }}
>
<div
className="w-full max-w-lg p-6 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="run-workflow"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Workflow
</label>
<select
id="run-workflow"
value={workflow}
onChange={(e) => setWorkflow(e.target.value)}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
>
<option value="">Select a workflow...</option>
{workflows.status === "ok" &&
workflows.data.workflows.map((w) => (
<option key={w.name} value={w.name}>
{w.name}
</option>
))}
</select>
</div>
<div>
<label
htmlFor="run-prompt"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Prompt
</label>
<textarea
id="run-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
className="w-full px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
placeholder="Enter the task prompt..."
/>
</div>
<div>
<label
htmlFor="run-max-rounds"
className="text-sm block mb-1"
style={{ color: "var(--color-text-muted)" }}
>
Max Rounds
</label>
<input
id="run-max-rounds"
type="number"
value={maxRounds}
onChange={(e) => setMaxRounds(Number(e.target.value))}
min={1}
max={100}
className="w-24 px-3 py-2 rounded border text-sm"
style={{
background: "var(--color-bg)",
borderColor: "var(--color-border)",
color: "var(--color-text)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--color-error)" }}>
{error}
</p>
)}
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded border"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !workflow || !prompt}
className="px-4 py-2 text-sm rounded"
style={{
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
color: "#fff",
opacity: !workflow || !prompt ? 0.5 : 1,
}}
>
{submitting ? "Starting..." : "Run"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
type Props = {
view: "threads" | "workflows";
onViewChange: (v: "threads" | "workflows") => void;
};
export function Sidebar({ view, onViewChange }: Props) {
const items = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
return (
<aside
className="w-56 border-r flex flex-col"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
Dashboard
</p>
</div>
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
<button
type="button"
key={item.key}
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}}
>
{item.icon} {item.label}
</button>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,38 @@
import { getHealth } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onRun: () => void;
};
export function StatusBar({ onRun }: Props) {
const health = useFetch(() => getHealth(), []);
return (
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div className="flex items-center gap-4">
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
<button
type="button"
onClick={onRun}
className="px-3 py-1 rounded text-xs font-medium"
style={{ background: "var(--color-accent)", color: "#fff" }}
>
Run Thread
</button>
</div>
<span>
{health.status === "loading" && "⏳ Connecting..."}
{health.status === "ok" && (
<span style={{ color: "var(--color-success)" }}> Connected</span>
)}
{health.status === "error" && (
<span style={{ color: "var(--color-error)" }}> Offline</span>
)}
</span>
</div>
);
}
@@ -0,0 +1,113 @@
import { useState } from "react";
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ threadId, onBack }: Props) {
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
async function handleAction(action: "kill" | "pause" | "resume") {
setActionStatus(`${action}ing...`);
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(threadId);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
}
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={onBack}
className="text-sm hover:underline"
style={{ color: "var(--color-accent)" }}
>
Back to threads
</button>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleAction("pause")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
>
Pause
</button>
<button
type="button"
onClick={() => handleAction("resume")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
>
Resume
</button>
<button
type="button"
onClick={() => handleAction("kill")}
className="px-3 py-1 text-xs rounded border"
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
>
Kill
</button>
</div>
</div>
<h2 className="text-xl font-semibold mb-2 font-mono">{threadId}</h2>
{actionStatus && (
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
{actionStatus}
</p>
)}
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{status === "ok" && (
<div className="space-y-3">
{data.records.map((r) => (
<div
key={`${r.type}:${r.role ?? ""}:${r.timestamp ?? 0}:${String(r.content ?? "")}`}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
>
{r.type}
</span>
{r.role && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{r.role}
</span>
)}
{r.timestamp && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{r.content && (
<pre
className="whitespace-pre-wrap text-xs mt-1"
style={{ color: "var(--color-text)" }}
>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,69 @@
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onSelect: (id: string) => void;
};
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const threads = data.threads;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Threads</h2>
{threads.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
) : (
<div className="space-y-2">
{threads.map((t) => (
<button
type="button"
key={t.threadId}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
{t.status && (
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "running"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-text-muted)",
color: "#000",
}}
>
{t.status}
</span>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.startedAt}
</p>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const workflows = data.workflows;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
{workflows.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code
className="text-xs mt-1 block font-mono"
style={{ color: "var(--color-accent)" }}
>
{w.currentHash}
</code>
</div>
))}
</div>
)}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
type FetchState<T> =
| { status: "loading"; data: null; error: null }
| { status: "ok"; data: T; error: null }
| { status: "error"; data: null; error: string };
export function useFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "loading",
data: null,
error: null,
});
useEffect(() => {
let cancelled = false;
setState({ status: "loading", data: null, error: null });
fetcher()
.then((data) => {
if (!cancelled) setState({ status: "ok", data, error: null });
})
.catch((err: unknown) => {
if (!cancelled)
setState({
status: "error",
data: null,
error: err instanceof Error ? err.message : String(err),
});
});
return () => {
cancelled = true;
};
// biome-ignore lint/correctness/useExhaustiveDependencies: this helper intentionally accepts caller-provided dependency arrays
}, deps);
return state;
}
+21
View File
@@ -0,0 +1,21 @@
@import "tailwindcss";
:root {
--color-bg: #0a0a0f;
--color-surface: #12121a;
--color-border: #1e1e2e;
--color-text: #e4e4ef;
--color-text-muted: #6b6b8a;
--color-accent: #7c6df0;
--color-accent-dim: #5a4db8;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./app.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:7860",
changeOrigin: true,
},
},
},
});
@@ -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";
@@ -1,15 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs"; import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
import { createLlmAdapter } from "../src/create-llm-adapter.js"; import { createLlmAdapter } from "../src/create-llm-adapter.js";
const casDir = mkdtempSync(join(tmpdir(), "wf-llm-adapter-cas-")); function makeCtx(userContent: string): AgentContext {
const testCas = createCasStore(casDir);
function makeCtx(userContent: string): ThreadContext {
return { return {
start: { start: {
role: START, role: START,
@@ -21,7 +15,6 @@ function makeCtx(userContent: string): ThreadContext {
steps: [], steps: [],
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "planner", systemPrompt: "system instructions" }, currentRole: { name: "planner", systemPrompt: "system instructions" },
cas: testCas,
}; };
} }
+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,170 @@
import type * as z from "zod/v4";
import {
type AgentBinding,
type AgentContext,
type AgentFn,
type CasStore,
END,
type ExtractContext,
type ModeratorContext,
type RoleDefinition,
type RoleMeta,
type RoleOutput,
type RoleStep,
START,
type ThreadContext,
type WorkflowCompletion,
type WorkflowDefinition,
type WorkflowFn,
type WorkflowRuntime,
} from "./types.js";
function isRoleNext<M extends RoleMeta>(
next: (keyof M & string) | typeof END,
): next is keyof M & string {
return next !== END;
}
function resolveExtractedRefs(
roleDef: RoleDefinition<Record<string, unknown>>,
meta: unknown,
): string[] {
const extractRefsFn = roleDef.extractRefs;
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
return [];
}
return extractRefsFn(meta as Record<string, unknown>);
}
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
return store.put(raw);
}
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
const overrides = binding.overrides;
const overrideFn: AgentFn | undefined =
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
return overrideFn !== undefined ? overrideFn : binding.agent;
}
type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
async function advanceOneRound<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
params: {
thread: ModeratorContext<M>;
runtime: WorkflowRuntime;
},
): Promise<AdvanceOutcome<M>> {
const { thread, runtime } = params;
const modCtx: ModeratorContext<M> = thread;
const next = def.moderator(modCtx);
if (!isRoleNext(next)) {
return {
kind: "complete",
completion: { returnCode: 0, summary: "completed: moderator returned END" },
};
}
const roleDef = def.roles[next];
if (roleDef === undefined) {
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
}
const agentCtx: AgentContext<M> = {
...modCtx,
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
};
const agent = agentForRole(binding, next);
const raw = await agent(agentCtx as unknown as AgentContext);
const extractCtx: ExtractContext<M> = {
...agentCtx,
agentContent: raw,
};
const meta = await runtime.extract(
roleDef.schema as z.ZodType<Record<string, unknown>>,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
);
const contentHash = await putContentBlob(runtime.cas, raw);
const refsFromMeta = resolveExtractedRefs(
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
meta,
);
const refs = refsFromMeta.includes(contentHash) ? refsFromMeta : [...refsFromMeta, contentHash];
const step = {
role: next,
contentHash,
meta,
refs,
timestamp: Date.now(),
} as RoleStep<M>;
return {
kind: "yield",
output: {
role: step.role,
contentHash: step.contentHash,
meta: step.meta,
refs: step.refs,
},
step,
};
}
/**
* Binds pure role definitions + moderator to runtime agents.
* Assign with `export const run = createWorkflow(def, binding)`.
*
* Structured meta extraction is delegated to {@link WorkflowRuntime.extract}, which the
* engine resolves from the workflow registry's `extract` scene.
*/
export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding,
): WorkflowFn {
return async function* workflowLoop(
thread: ThreadContext,
runtime: WorkflowRuntime,
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
if (thread.start.role !== START) {
throw new Error(`workflow loop expected start role to be ${START}`);
}
const maxRounds = thread.start.meta.maxRounds;
let currentThread = thread as ModeratorContext<M>;
while (true) {
if (currentThread.steps.length >= maxRounds) {
return {
returnCode: 0,
summary: `completed: reached maxRounds (${maxRounds})`,
};
}
const outcome = await advanceOneRound(def, binding, {
thread: currentThread,
runtime,
});
if (outcome.kind === "complete") {
return outcome.completion;
}
yield outcome.output;
currentThread = {
...currentThread,
steps: [...currentThread.steps, outcome.step],
};
}
};
}
+29
View File
@@ -0,0 +1,29 @@
export { createWorkflow } from "./create-workflow.js";
export { err, ok } from "./result.js";
export type {
AgentBinding,
AgentContext,
AgentFn,
CasStore,
ExtractContext,
ExtractFn,
LlmProvider,
Moderator,
ModeratorContext,
Result,
RoleDefinition,
RoleMeta,
RoleOutput,
RoleStep,
StartStep,
ThreadContext,
WorkflowCompletion,
WorkflowDefinition,
WorkflowDescriptor,
WorkflowFn,
WorkflowResult,
WorkflowRoleDescriptor,
WorkflowRoleSchema,
WorkflowRuntime,
} from "./types.js";
export { END, START } from "./types.js";
+9
View File
@@ -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 };
}
@@ -1,11 +1,33 @@
import type * as z from "zod/v4"; import type * as z from "zod/v4";
import type { CasStore } from "./cas/index.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;
export const END = "__end__" as const; export const END = "__end__" as const;
export type CasStore = {
put(content: string): Promise<string>;
get(hash: string): Promise<string | null>;
delete(hash: string): Promise<void>;
list(): Promise<string[]>;
};
/** 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>;
};
/** Expected success/failure outcome without throwing for recoverable errors. */
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
/** Maps role names → their meta types. Single generic drives all inference. */ /** Maps role names → their meta types. Single generic drives all inference. */
export type RoleMeta = Record<string, Record<string, unknown>>; export type RoleMeta = Record<string, Record<string, unknown>>;
@@ -16,9 +38,6 @@ export type LlmProvider = {
model: string; model: string;
}; };
/** How the engine runs meta extraction for a role after the agent phase. */
export type ExtractMode = "single" | "react";
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */ /** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
export type RoleOutput = { export type RoleOutput = {
role: string; role: string;
@@ -35,31 +54,23 @@ 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;
}; };
/** Input to a workflow — prompt plus optional historical steps for fork/resume. */ /** Runtime dependencies passed to a workflow bundle's `run` export (engine-provided). */
export type ThreadInput = { export type WorkflowRuntime = {
prompt: string;
steps: RoleOutput[];
};
/** Options passed to a workflow bundle's `run` export (engine-provided). */
export type WorkflowFnOptions = {
threadId: string;
maxRounds: number;
/** Nesting depth for workflow-as-agent chains; root threads use `0`. */
depth: number;
/** 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;
}; };
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */ /** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
export type WorkflowFn = ( export type WorkflowFn = (
input: ThreadInput, thread: ThreadContext,
options: WorkflowFnOptions, runtime: WorkflowRuntime,
) => AsyncGenerator<RoleOutput, WorkflowCompletion>; ) => AsyncGenerator<RoleOutput, WorkflowCompletion>;
/** Engine start frame: initial prompt + thread identity. */ /** Engine start frame: initial prompt + thread identity. */
@@ -81,22 +92,24 @@ export type RoleStep<M extends RoleMeta> = {
}; };
}[keyof M & string]; }[keyof M & string];
/** Phase 1: Moderator decides next role. */ /** Thread runtime context shared by moderator/agent/extractor phases. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = { export type ThreadContext<M extends RoleMeta = RoleMeta> = {
threadId: string; threadId: string;
/** Same as `WorkflowFnOptions.depth` for the active thread. */ /** Nesting depth for workflow-as-agent chains; root threads use `0`. */
depth: number; depth: number;
start: StartStep; start: StartStep;
steps: RoleStep<M>[]; steps: RoleStep<M>[];
}; };
/** Phase 1: Moderator decides next role. */
export type ModeratorContext<M extends RoleMeta = RoleMeta> = ThreadContext<M>;
/** Phase 2: Agent executes — knows its role and prompt. */ /** Phase 2: Agent executes — knows its role and prompt. */
export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & { export type AgentContext<M extends RoleMeta = RoleMeta> = ModeratorContext<M> & {
currentRole: { currentRole: {
name: string; name: string;
systemPrompt: string; systemPrompt: string;
}; };
cas: CasStore;
}; };
/** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */ /** Phase 3: Extractor runs — has agent output; the extraction instruction is a separate argument to the extract function. */
@@ -104,16 +117,19 @@ export type ExtractContext<M extends RoleMeta = RoleMeta> = AgentContext<M> & {
agentContent: string; agentContent: string;
}; };
/** Alias — most external consumers see the agent-phase context. */ export type ExtractFn = <T extends Record<string, unknown>>(
export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>; schema: z.ZodType<T>,
prompt: string,
ctx: ExtractContext,
) => Promise<T>;
/** 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. */
@@ -124,7 +140,6 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
schema: z.ZodType<Meta>; schema: z.ZodType<Meta>;
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */ /** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
extractRefs: ((meta: Meta) => string[]) | null; extractRefs: ((meta: Meta) => string[]) | null;
extractMode: ExtractMode;
}; };
/** /**
@@ -143,3 +158,8 @@ 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>;
}; };
/** Internal outcome of advancing one moderator round inside {@link createWorkflow}. */
export type AdvanceOutcome<M extends RoleMeta> =
| { kind: "complete"; completion: WorkflowCompletion }
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
+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"]
}
+4 -5
View File
@@ -2,7 +2,7 @@
Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit. Reference **develop** workflow template: plan phases, implement in a loop, review, test, then commit.
Export a `WorkflowDefinition` and `createDevelopRun` so a host can bind agents/LLM and run the same graph the bundled `.esm.js` would use. Use `buildDevelopDescriptor()` when assembling `descriptor` metadata for a bundle. Export a pure `WorkflowDefinition` (`developWorkflowDefinition`) and role/moderator pieces. Workflow instantiation (`createWorkflow(definition, binding)`) happens in the workflow instance layer, not in this template package.
## Install ## Install
@@ -15,10 +15,10 @@ In this monorepo: `workspace:*` for `@uncaged/workflow-template-develop` and `@u
## Usage ## Usage
```typescript ```typescript
import { createDevelopRun, developWorkflowDefinition } from "@uncaged/workflow-template-develop"; import { createWorkflow } from "@uncaged/workflow";
import { developWorkflowDefinition } from "@uncaged/workflow-template-develop";
const run = createDevelopRun(binding, extract, llmProvider); const run = createWorkflow(developWorkflowDefinition, binding);
// run(...) executes the develop moderator graph with your AgentBinding
``` ```
## Roles ## Roles
@@ -46,7 +46,6 @@ Also exported: role factories/meta schemas (`plannerRole`, `coderRole`, …), `D
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `createDevelopRun` | `createWorkflow(developWorkflowDefinition, …)` factory |
| `developWorkflowDefinition` | `description`, `roles`, `developModerator` | | `developWorkflowDefinition` | `description`, `roles`, `developModerator` |
| `developModerator` | `Moderator<DevelopMeta>` | | `developModerator` | `Moderator<DevelopMeta>` |
| `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata | | `buildDevelopDescriptor` | `buildDescriptor({ … })` for bundle metadata |
@@ -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,4 @@
import { import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
type AgentBinding,
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";
@@ -42,11 +35,3 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
roles: developRoles, roles: developRoles,
moderator: developModerator, moderator: developModerator,
}; };
export function createDevelopRun(
binding: AgentBinding,
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\`.
@@ -31,5 +31,4 @@ export const coderRole: RoleDefinition<CoderMeta> = {
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.", "Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
schema: coderMetaSchema, schema: coderMetaSchema,
extractRefs: (meta) => [meta.completedPhase], extractRefs: (meta) => [meta.completedPhase],
extractMode: "single",
}; };
@@ -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", [
@@ -32,5 +32,4 @@ export const committerRole: RoleDefinition<CommitterMeta> = {
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.", "Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
schema: committerMetaSchema, schema: committerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -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\`.
@@ -48,5 +48,4 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).", "Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash), extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
}; };
@@ -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", [
@@ -41,5 +41,4 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.", "Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
schema: reviewerMetaSchema, schema: reviewerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -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", [
@@ -23,5 +23,4 @@ export const testerRole: RoleDefinition<TesterMeta> = {
"Extract the verification result: passed with summary details, or failed with details of what broke.", "Extract the verification result: passed with summary details, or failed with details of what broke.",
schema: testerMetaSchema, schema: testerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -2,7 +2,7 @@
Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR). Reference **solve-issue** workflow template: prepare a repo, delegate implementation to the **develop** workflow, then submit (e.g. open a PR).
`createSolveIssueRun` wires the `developer` role to `workflowAsAgent("develop")` by default; `binding.overrides.developer` wins if you pass one (for tests or custom hosts). This package exports a pure `WorkflowDefinition` (`solveIssueWorkflowDefinition`). Workflow instantiation (`createWorkflow(definition, binding)`) and any role-specific agent wiring (for example delegating `developer` to `workflowAsAgent("develop")`) are done in the workflow instance layer.
## Install ## Install
@@ -15,9 +15,10 @@ In this monorepo: `workspace:*` for this package and `@uncaged/workflow`.
## Usage ## Usage
```typescript ```typescript
import { createSolveIssueRun, solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue"; import { createWorkflow } from "@uncaged/workflow";
import { solveIssueWorkflowDefinition } from "@uncaged/workflow-template-solve-issue";
const run = createSolveIssueRun(binding, extract, llmProvider); const run = createWorkflow(solveIssueWorkflowDefinition, binding);
``` ```
## Roles ## Roles
@@ -41,7 +42,6 @@ Also exported: `preparerRole`, `developerRole`, `submitterRole` and their Zod me
| Export | Description | | Export | Description |
|--------|-------------| |--------|-------------|
| `createSolveIssueRun` | Merges `developer` override with `workflowAsAgent("develop")`, then `createWorkflow` |
| `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` | | `solveIssueWorkflowDefinition` | `description`, `roles`, `solveIssueModerator` |
| `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` | | `solveIssueModerator` | Linear `Moderator<SolveIssueMeta>` |
| `buildSolveIssueDescriptor` | Descriptor helper for bundles | | `buildSolveIssueDescriptor` | Descriptor helper for bundles |
@@ -2,18 +2,17 @@ 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, createWorkflow } 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 { solveIssueModerator, solveIssueWorkflowDefinition } from "../src/index.js";
import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js"; import type { PreparerMeta, SubmitterMeta } from "../src/roles/index.js";
import type { SolveIssueMeta } from "../src/roles.js"; import type { SolveIssueMeta } from "../src/roles.js";
@@ -24,46 +23,7 @@ function jsonResponse(payload: Record<string, unknown>): Response {
}); });
} }
function readToolListFromBody(init: RequestInit | undefined): readonly Record<string, unknown>[] { function buildPlainJsonResponse(args: Record<string, unknown>): Response {
if (init === undefined || init.body === undefined || init.body === null) {
return [];
}
const body = JSON.parse(String(init.body)) as Record<string, unknown>;
const tools = body.tools;
if (!Array.isArray(tools)) {
return [];
}
return tools.filter((t): t is Record<string, unknown> => t !== null && typeof t === "object");
}
function singleToolName(tools: readonly Record<string, unknown>[]): string {
if (tools.length === 0) {
return "extract";
}
const fn = tools[0].function as Record<string, unknown> | undefined;
return typeof fn?.name === "string" ? fn.name : "extract";
}
function buildSingleModeResponse(args: Record<string, unknown>, toolName: string): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
type: "function",
function: { name: toolName, arguments: JSON.stringify(args) },
},
],
},
},
],
});
}
function buildReactModeResponse(args: Record<string, unknown>): Response {
// reactExtract accepts a plain-JSON assistant message and validates it
// directly against the schema, so we skip the cas_get / extract tool dance.
return jsonResponse({ return jsonResponse({
choices: [{ message: { content: JSON.stringify(args) } }], choices: [{ message: { content: JSON.stringify(args) } }],
}); });
@@ -74,18 +34,59 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
let i = 0; let i = 0;
const mockFetch = async ( const mockFetch = async (
_input: Parameters<typeof fetch>[0], _input: Parameters<typeof fetch>[0],
init?: RequestInit, _init?: RequestInit,
): Promise<Response> => { ): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1]; const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) { if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence"); throw new Error("installMockChatCompletions: empty sequence");
} }
i += 1; i += 1;
const tools = readToolListFromBody(init); return buildPlainJsonResponse(args);
if (tools.length > 1) { };
return buildReactModeResponse(args); globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch),
}) as typeof fetch;
return () => {
globalThis.fetch = origFetch;
};
}
function buildToolCallResponse(args: Record<string, unknown>): Response {
return jsonResponse({
choices: [
{
message: {
tool_calls: [
{
id: "tc_extract_1",
type: "function",
function: {
name: "extract",
arguments: JSON.stringify(args),
},
},
],
},
},
],
});
}
function installMockToolCallCompletions(
sequence: ReadonlyArray<Record<string, unknown>>,
): () => void {
const origFetch = globalThis.fetch;
let i = 0;
const mockFetch = async (
_input: Parameters<typeof fetch>[0],
_init?: RequestInit,
): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) {
throw new Error("installMockToolCallCompletions: empty sequence");
} }
return buildSingleModeResponse(args, singleToolName(tools)); i += 1;
return buildToolCallResponse(args);
}; };
globalThis.fetch = Object.assign(mockFetch, { globalThis.fetch = Object.assign(mockFetch, {
preconnect: origFetch.preconnect.bind(origFetch), preconnect: origFetch.preconnect.bind(origFetch),
@@ -161,17 +162,30 @@ function submitterStep(meta: SubmitterMeta): RoleStep<SolveIssueMeta> {
}; };
} }
const stubExtract = createExtract({ function createStubExtract(casDir: string) {
baseUrl: "http://127.0.0.1:9", return createExtract(
apiKey: "", {
model: "test", baseUrl: "http://127.0.0.1:9",
}); apiKey: "",
model: "test",
},
{ cas: createCasStore(casDir) },
);
}
const stubLlmProvider = { function makeThread(prompt: string) {
baseUrl: "http://127.0.0.1:9", return {
apiKey: "", threadId: "01TEST000000000000000000TR",
model: "test", depth: 0,
}; start: {
role: START,
content: prompt,
meta: { maxRounds: 20 },
timestamp: Date.now(),
},
steps: [],
};
}
describe("solveIssueModerator", () => { describe("solveIssueModerator", () => {
test("routes initial → preparer → developer → submitter → END", () => { test("routes initial → preparer → developer → submitter → END", () => {
@@ -219,7 +233,7 @@ describe("solveIssueModerator", () => {
}); });
}); });
describe("createSolveIssueRun", () => { describe("solveIssueWorkflowDefinition + createWorkflow", () => {
let restoreFetch: (() => void) | null = null; let restoreFetch: (() => void) | null = null;
let casDir: string | undefined; let casDir: string | undefined;
@@ -249,19 +263,48 @@ describe("createSolveIssueRun", () => {
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
// Override developer so the test does not spin up a child workflow. const run = createWorkflow(solveIssueWorkflowDefinition, {
const run = createSolveIssueRun( agent: async () => "",
{ overrides: { developer: async () => "stub-root-hash" },
agent: async () => "", });
overrides: { developer: async () => "stub-root-hash" }, const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next();
expect(first.done).toBe(false);
if (first.done) {
throw new Error("expected yield");
}
expect(first.value.role).toBe("preparer");
expect(first.value.meta).toEqual(EXPECT_PREPARER_META);
});
test("structured extraction also accepts tool_calls extraction path", async () => {
const EXPECT_PREPARER_META: PreparerMeta = {
repoPath: "/home/user/repos/tool-call",
defaultBranch: "main",
conventions: null,
toolchain: {
packageManager: "bun",
testCommand: "bun test",
lintCommand: null,
buildCommand: "bun run build",
}, },
stubExtract, };
stubLlmProvider, restoreFetch = installMockToolCallCompletions([EXPECT_PREPARER_META]);
);
const gen = run( casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
{ prompt: "task", steps: [] }, const cas = createCasStore(casDir);
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
); const run = createWorkflow(solveIssueWorkflowDefinition, {
agent: async () => "",
overrides: { developer: async () => "stub-root-hash" },
});
const gen = run(makeThread("task"), {
cas,
extract: createStubExtract(casDir),
});
const first = await gen.next(); const first = await gen.next();
expect(first.done).toBe(false); expect(first.done).toBe(false);
if (first.done) { if (first.done) {
@@ -294,34 +337,30 @@ describe("createSolveIssueRun", () => {
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const calls: string[] = []; const calls: string[] = [];
const run = createSolveIssueRun( const run = createWorkflow(solveIssueWorkflowDefinition, {
{ agent: async () => {
agent: async () => { calls.push("default");
calls.push("default"); return "";
},
overrides: {
preparer: async () => {
calls.push("preparer");
return ""; return "";
}, },
overrides: { developer: async () => {
preparer: async () => { calls.push("developer");
calls.push("preparer"); return "stub-root-hash";
return ""; },
}, submitter: async () => {
developer: async () => { calls.push("submitter");
calls.push("developer"); return "";
return "stub-root-hash";
},
submitter: async () => {
calls.push("submitter");
return "";
},
}, },
}, },
stubExtract, });
stubLlmProvider, const gen = run(makeThread("task"), {
); cas,
const gen = run( extract: createStubExtract(casDir),
{ prompt: "task", steps: [] }, });
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
);
await gen.next(); await gen.next();
expect(calls).toEqual(["preparer"]); expect(calls).toEqual(["preparer"]);
@@ -333,55 +372,6 @@ describe("createSolveIssueRun", () => {
await gen.next(); await gen.next();
expect(calls).toEqual(["submitter"]); expect(calls).toEqual(["submitter"]);
}); });
test("developer defaults to workflowAsAgent override (caller override still wins)", async () => {
const PREPARER_META: PreparerMeta = {
repoPath: "/tmp/r",
defaultBranch: "main",
conventions: null,
toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null },
};
const DEVELOPER_META: DeveloperMeta = {
branch: "feat/y",
commitSha: "def5678",
filesChanged: ["b.ts"],
summary: "more work",
};
restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]);
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir);
let developerInvocations = 0;
const run = createSolveIssueRun(
{
agent: async () => "",
overrides: {
developer: async () => {
developerInvocations += 1;
return "stub-root-hash";
},
},
},
stubExtract,
stubLlmProvider,
);
const gen = run(
{ prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
);
// preparer
await gen.next();
// developer (caller override should be invoked, NOT workflowAsAgent default)
const devYield = await gen.next();
expect(devYield.done).toBe(false);
if (devYield.done) {
throw new Error("expected yield");
}
expect(devYield.value.role).toBe("developer");
expect(devYield.value.meta).toEqual(DEVELOPER_META);
expect(developerInvocations).toBe(1);
});
}); });
describe("buildSolveIssueDescriptor", () => { describe("buildSolveIssueDescriptor", () => {
@@ -32,8 +32,7 @@ describe("submitterRole", () => {
expect(submitterRole.systemPrompt).toContain("pull request"); expect(submitterRole.systemPrompt).toContain("pull request");
}); });
test("uses single extract mode without refs", () => { test("has no refs extractor", () => {
expect(submitterRole.extractMode).toBe("single");
expect(submitterRole.extractRefs).toBeNull(); expect(submitterRole.extractRefs).toBeNull();
}); });
}); });
@@ -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({
@@ -33,5 +33,4 @@ export const developerRole: RoleDefinition<DeveloperMeta> = {
extractPrompt: DEVELOPER_EXTRACT_PROMPT, extractPrompt: DEVELOPER_EXTRACT_PROMPT,
schema: developerMetaSchema, schema: developerMetaSchema,
extractRefs: () => [], extractRefs: () => [],
extractMode: "react",
}; };
@@ -1,12 +1,4 @@
import { import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
type AgentBinding,
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";
@@ -38,26 +30,3 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
roles: solveIssueRoles, roles: solveIssueRoles,
moderator: solveIssueModerator, moderator: solveIssueModerator,
}; };
/**
* Build the solve-issue {@link WorkflowFn}.
*
* The `developer` role always delegates to the registered `develop` workflow via
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
*/
export function createSolveIssueRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
const mergedBinding: AgentBinding = {
agent: binding.agent,
overrides: {
...(binding.overrides ?? {}),
developer: developerOverride,
},
};
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
}
@@ -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({
@@ -48,5 +48,4 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).", "Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
schema: preparerMetaSchema, schema: preparerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -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", [
@@ -40,5 +40,4 @@ export const submitterRole: RoleDefinition<SubmitterMeta> = {
extractPrompt: SUBMITTER_EXTRACT_PROMPT, extractPrompt: SUBMITTER_EXTRACT_PROMPT,
schema: submitterMetaSchema, schema: submitterMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -1,12 +1,9 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises"; import { type AgentContext, START } from "@uncaged/workflow-runtime";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
import { buildAgentPrompt } from "../src/index.js"; import { buildAgentPrompt } from "../src/index.js";
function startTask(content: string): ThreadContext["start"] { function startTask(content: string): AgentContext["start"] {
return { return {
role: START, role: START,
content, content,
@@ -16,25 +13,13 @@ function startTask(content: string): ThreadContext["start"] {
} }
describe("buildAgentPrompt", () => { describe("buildAgentPrompt", () => {
let casRoot: string;
beforeEach(async () => {
casRoot = await mkdtemp(join(tmpdir(), "wf-build-prompt-cas-"));
});
afterEach(async () => {
await rm(casRoot, { recursive: true, force: true });
});
test("includes system prompt and full task; omits tools when there are no steps", async () => { test("includes system prompt and full task; omits tools when there are no steps", async () => {
const cas = createCasStore(casRoot); const ctx: AgentContext = {
const ctx: ThreadContext = {
start: startTask("fix the bug"), start: startTask("fix the bug"),
depth: 0, depth: 0,
steps: [], steps: [],
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: START, systemPrompt: "You are an agent." }, currentRole: { name: START, systemPrompt: "You are an agent." },
cas,
}; };
const text = await buildAgentPrompt(ctx); const text = await buildAgentPrompt(ctx);
expect(text).toContain("You are an agent."); expect(text).toContain("You are an agent.");
@@ -43,15 +28,13 @@ describe("buildAgentPrompt", () => {
expect(text).not.toContain("## Tools"); expect(text).not.toContain("## Tools");
}); });
test("single step shows full content and meta, and includes tools", async () => { test("single step shows hash and meta, and includes tools", async () => {
const cas = createCasStore(casRoot); const onlyHash = "01HASHSINGLESTEP0000000001";
const onlyHash = await putContentMerkleNode(cas, "only step full body"); const ctx: AgentContext = {
const ctx: ThreadContext = {
start: startTask("user task"), start: startTask("user task"),
depth: 0, depth: 0,
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "Be helpful." }, currentRole: { name: "coder", systemPrompt: "Be helpful." },
cas,
steps: [ steps: [
{ {
role: "coder", role: "coder",
@@ -66,22 +49,20 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Task"); expect(text).toContain("## Task");
expect(text).toContain("user task"); expect(text).toContain("user task");
expect(text).toContain("## Step: coder"); expect(text).toContain("## Step: coder");
expect(text).toContain("only step full body"); expect(text).toContain(`ContentHash: ${onlyHash}`);
expect(text).toContain('Meta: {"files":["a.ts"]}'); expect(text).toContain('Meta: {"files":["a.ts"]}');
expect(text).toContain("## Tools"); expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR"); expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
}); });
test("two or more steps: previous steps are meta-only; latest step is full", async () => { test("two or more steps: previous steps are meta-only; latest step includes hash", async () => {
const cas = createCasStore(casRoot); const plannerHash = "01HASHPLANNER0000000000001";
const plannerHash = await putContentMerkleNode(cas, "PLANNER_SECRET_FULL_TEXT"); const coderHash = "01HASHCODER0000000000000001";
const coderHash = await putContentMerkleNode(cas, "last step full content"); const ctx: AgentContext = {
const ctx: ThreadContext = {
start: startTask("first message full: task content here"), start: startTask("first message full: task content here"),
depth: 0, depth: 0,
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "coder", systemPrompt: "System." }, currentRole: { name: "coder", systemPrompt: "System." },
cas,
steps: [ steps: [
{ {
role: "planner", role: "planner",
@@ -104,25 +85,22 @@ describe("buildAgentPrompt", () => {
expect(text).toContain("## Previous Steps"); expect(text).toContain("## Previous Steps");
expect(text).toContain("### Step 1: planner"); expect(text).toContain("### Step 1: planner");
expect(text).toContain('Summary: {"plan":"short"}'); expect(text).toContain('Summary: {"plan":"short"}');
expect(text).not.toContain("PLANNER_SECRET_FULL_TEXT");
expect(text).toContain("## Latest Step: coder"); expect(text).toContain("## Latest Step: coder");
expect(text).toContain("last step full content"); expect(text).toContain(`ContentHash: ${coderHash}`);
expect(text).toContain('Meta: {"done":true}'); expect(text).toContain('Meta: {"done":true}');
expect(text).toContain("## Tools"); expect(text).toContain("## Tools");
expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR"); expect(text).toContain("uncaged-workflow thread 01TEST000000000000000000TR");
}); });
test("middle steps show meta summary only, not full content", async () => { test("middle steps show meta summary only and latest shows hash", async () => {
const cas = createCasStore(casRoot); const ha = "01HASHA00000000000000000001";
const ha = await putContentMerkleNode(cas, "HIDDEN_A"); const hb = "01HASHB00000000000000000001";
const hb = await putContentMerkleNode(cas, "HIDDEN_B_MIDDLE"); const hc = "01HASHC00000000000000000001";
const hc = await putContentMerkleNode(cas, "VISIBLE_LAST"); const ctx: AgentContext = {
const ctx: ThreadContext = {
start: startTask("start"), start: startTask("start"),
depth: 0, depth: 0,
threadId: "01TEST000000000000000000TR", threadId: "01TEST000000000000000000TR",
currentRole: { name: "c", systemPrompt: "S" }, currentRole: { name: "c", systemPrompt: "S" },
cas,
steps: [ steps: [
{ {
role: "a", role: "a",
@@ -148,11 +126,9 @@ describe("buildAgentPrompt", () => {
], ],
}; };
const text = await buildAgentPrompt(ctx); const text = await buildAgentPrompt(ctx);
expect(text).not.toContain("HIDDEN_A");
expect(text).not.toContain("HIDDEN_B_MIDDLE");
expect(text).toContain('Summary: {"n":1}'); expect(text).toContain('Summary: {"n":1}');
expect(text).toContain('Summary: {"n":2}'); expect(text).toContain('Summary: {"n":2}');
expect(text).toContain("VISIBLE_LAST"); expect(text).toContain(`ContentHash: ${hc}`);
expect(text).toContain("## Latest Step: c"); expect(text).toContain("## Latest Step: c");
}); });
}); });
+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,13 +1,4 @@
import type { AgentContext } from "@uncaged/workflow"; import type { AgentContext } from "@uncaged/workflow-runtime";
import { getContentMerklePayload } from "@uncaged/workflow";
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
const text = await getContentMerklePayload(ctx.cas, contentHash);
if (text === null) {
throw new Error(`buildAgentPrompt: missing CAS blob for ${contentHash}`);
}
return text;
}
/** Builds the full agent prompt: system instructions plus summarized thread history. */ /** Builds the full agent prompt: system instructions plus summarized thread history. */
export async function buildAgentPrompt(ctx: AgentContext): Promise<string> { export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
@@ -24,12 +15,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
if (steps.length === 1) { if (steps.length === 1) {
const s = steps[0]; const s = steps[0];
const body = await resolveStepText(ctx, s.contentHash);
lines.push(""); lines.push("");
lines.push(`## Step: ${s.role}`); lines.push(`## Step: ${s.role}`);
lines.push(""); lines.push("");
lines.push(body); lines.push(`ContentHash: ${s.contentHash}`);
lines.push("");
lines.push(`Meta: ${JSON.stringify(s.meta)}`); lines.push(`Meta: ${JSON.stringify(s.meta)}`);
} else { } else {
lines.push(""); lines.push("");
@@ -41,12 +30,10 @@ export async function buildAgentPrompt(ctx: AgentContext): Promise<string> {
lines.push(`Summary: ${JSON.stringify(s.meta)}`); lines.push(`Summary: ${JSON.stringify(s.meta)}`);
} }
const last = steps[steps.length - 1]; const last = steps[steps.length - 1];
const lastBody = await resolveStepText(ctx, last.contentHash);
lines.push(""); lines.push("");
lines.push(`## Latest Step: ${last.role}`); lines.push(`## Latest Step: ${last.role}`);
lines.push(""); lines.push("");
lines.push(lastBody); lines.push(`ContentHash: ${last.contentHash}`);
lines.push("");
lines.push(`Meta: ${JSON.stringify(last.meta)}`); lines.push(`Meta: ${JSON.stringify(last.meta)}`);
} }
@@ -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", () => {
@@ -23,7 +22,6 @@ describe("buildDescriptor", () => {
extractPrompt: "Extract title and count from the analysis.", extractPrompt: "Extract title and count from the analysis.",
schema, schema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
}, },
moderator: () => END, moderator: () => END,
@@ -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 () => {
+204 -49
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({
@@ -35,41 +33,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
let i = 0; let i = 0;
const mockFetch = async ( const mockFetch = async (
input: Parameters<typeof fetch>[0], _input: Parameters<typeof fetch>[0],
init?: RequestInit, _init?: RequestInit,
): Promise<Response> => { ): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1]; const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) { if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence"); throw new Error("installMockChatCompletions: empty sequence");
} }
i += 1; i += 1;
void input;
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const firstTool =
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
? (tools[0] as Record<string, unknown>)
: null;
const fn =
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
choices: [ choices: [{ message: { content: JSON.stringify(args) } }],
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
}), }),
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
); );
@@ -82,11 +56,95 @@ 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 reply with schema-shaped JSON in `content`; supervisor uses plain `content` (no tools advertised). */
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;
return new Response(
JSON.stringify({
choices: [{ message: { content: 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>(
{ {
@@ -97,7 +155,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
extractPrompt: "Extract plan text and affected files list.", extractPrompt: "Extract plan text and affected files list.",
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
coder: { coder: {
description: "Demo coder", description: "Demo coder",
@@ -105,7 +162,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
extractPrompt: "Extract the code diff summary.", extractPrompt: "Extract the code diff summary.",
schema: coderMetaSchema, schema: coderMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
}, },
moderator: (ctx) => { moderator: (ctx) => {
@@ -125,8 +181,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
coder: async () => "code-body", coder: async () => "code-body",
}, },
}, },
demoExtract,
null,
); );
describe("executeThread", () => { describe("executeThread", () => {
@@ -150,6 +204,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 +221,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,
@@ -248,7 +304,7 @@ describe("executeThread", () => {
} }
}); });
test("pre-filled ThreadInput.steps skips roles already present", async () => { test("pre-filled input.steps skips roles already present", async () => {
restoreFetch = installMockChatCompletions([{ diff: "+ok" }]); restoreFetch = installMockChatCompletions([{ diff: "+ok" }]);
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-")); const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
@@ -258,6 +314,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 +352,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 +412,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 +450,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 +467,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,
@@ -449,7 +510,7 @@ describe("executeThread", () => {
} }
}); });
test("extractMode react traverses CAS DAG via cas_get during extraction", async () => { test("extract traverses CAS DAG via cas_get during extraction", async () => {
const dagMetaSchema = z.object({ leafPayload: z.string() }); const dagMetaSchema = z.object({ leafPayload: z.string() });
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> }; type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
@@ -549,9 +610,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: {
@@ -562,14 +620,11 @@ describe("executeThread", () => {
"Set leafPayload to the string payload of the content Merkle node under the root.", "Set leafPayload to the string payload of the content Merkle node under the root.",
schema: dagMetaSchema, schema: dagMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "react",
}, },
}, },
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 +632,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 +648,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 +670,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({
@@ -29,41 +27,17 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
let i = 0; let i = 0;
const mockFetch = async ( const mockFetch = async (
input: Parameters<typeof fetch>[0], _input: Parameters<typeof fetch>[0],
init?: RequestInit, _init?: RequestInit,
): Promise<Response> => { ): Promise<Response> => {
const args = sequence[i] ?? sequence[sequence.length - 1]; const args = sequence[i] ?? sequence[sequence.length - 1];
if (args === undefined) { if (args === undefined) {
throw new Error("installMockChatCompletions: empty sequence"); throw new Error("installMockChatCompletions: empty sequence");
} }
i += 1; i += 1;
void input;
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
const tools = body.tools;
const firstTool =
Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object"
? (tools[0] as Record<string, unknown>)
: null;
const fn =
firstTool !== null ? (firstTool.function as Record<string, unknown> | undefined) : undefined;
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
choices: [ choices: [{ message: { content: JSON.stringify(args) } }],
{
message: {
tool_calls: [
{
type: "function",
function: {
name: toolName,
arguments: JSON.stringify(args),
},
},
],
},
},
],
}), }),
{ status: 200, headers: { "Content-Type": "application/json" } }, { status: 200, headers: { "Content-Type": "application/json" } },
); );
@@ -76,11 +50,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>(
{ {
@@ -91,16 +70,14 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
extractPrompt: "Extract phases with CAS hashes.", extractPrompt: "Extract phases with CAS hashes.",
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash), extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
}, },
}, },
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
}, },
{ {
agent: async () => "plan-output", agent: async () => "plan-output",
overrides: null,
}, },
refsDemoExtract,
null,
); );
describe("RoleStep refs tracking", () => { describe("RoleStep refs tracking", () => {
@@ -142,6 +119,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 +136,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,
+84 -16
View File
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
const yaml = ` const yaml = `
config: config:
maxDepth: 3 maxDepth: 3
extract: providers:
baseUrl: https://example.com/v1 dashscope:
model: qwen-plus baseUrl: https://example.com/v1
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:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1 dashscope:
model: qwen-plus baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
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:
baseUrl: https://example.com p:
model: m baseUrl: https://example.com
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);

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