Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 587518ac09 | |||
| e9e4960714 | |||
| 495c000356 | |||
| 7e662f9287 | |||
| 3ed38c65ec | |||
| 38f2b0eeb2 | |||
| 586a0f824e | |||
| 178f6c7519 | |||
| 3153ab26f6 | |||
| 014c442ed2 | |||
| 1f7851d5e3 | |||
| e68790dfc7 | |||
| 520b17b351 | |||
| 085cdcd3f4 | |||
| a8c1c158d6 | |||
| 83649fd836 | |||
| 9e6cd9d615 | |||
| 1f1128ff4a | |||
| aa01283ce1 |
@@ -3,3 +3,4 @@ dist/
|
|||||||
bun.lock
|
bun.lock
|
||||||
*.tgz
|
*.tgz
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
.npmrc
|
||||||
|
|||||||
@@ -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,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,
|
|
||||||
);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@uncaged/workflow-examples",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
|
||||||
"@uncaged/workflow": "workspace:*",
|
|
||||||
"zod": "^4.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -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 () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ export function formatCliUsage(
|
|||||||
);
|
);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Server:");
|
||||||
|
lines.push(
|
||||||
|
...formatUsageCommandLines([
|
||||||
|
{
|
||||||
|
prefix: "serve [--port N] [--host ADDR]",
|
||||||
|
description: "Start HTTP API server (default: 127.0.0.1:7860)",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
lines.push("Reference:");
|
lines.push("Reference:");
|
||||||
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
const skillTopicNames = skillTopics.map((t) => t.name).join(", ");
|
||||||
lines.push(
|
lines.push(
|
||||||
|
|||||||
@@ -13,19 +13,7 @@ import {
|
|||||||
templateTsconfigJson,
|
templateTsconfigJson,
|
||||||
} from "./templates.js";
|
} from "./templates.js";
|
||||||
import type { CmdInitTemplateSuccess } from "./types.js";
|
import type { CmdInitTemplateSuccess } from "./types.js";
|
||||||
|
import { validateWorkspaceSegment } from "./validate.js";
|
||||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
|
||||||
if (name.length === 0) {
|
|
||||||
return err("workspace name must not be empty");
|
|
||||||
}
|
|
||||||
if (name === "." || name === "..") {
|
|
||||||
return err("invalid workspace name");
|
|
||||||
}
|
|
||||||
if (name.includes("/") || name.includes("\\")) {
|
|
||||||
return err("workspace name must not contain path separators");
|
|
||||||
}
|
|
||||||
return ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
function hasTemplatesWorkspaceGlob(workspaces: unknown): boolean {
|
||||||
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
return Array.isArray(workspaces) && workspaces.includes("templates/*");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function templatePackageJson(templateName: string): string {
|
|||||||
type: "module",
|
type: "module",
|
||||||
dependencies: {
|
dependencies: {
|
||||||
"@uncaged/workflow": "^0.1.0",
|
"@uncaged/workflow": "^0.1.0",
|
||||||
|
"@uncaged/workflow-runtime": "^0.1.0",
|
||||||
zod: "^4.0.0",
|
zod: "^4.0.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -31,7 +32,7 @@ export function templateTsconfigJson(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateRolesTs(): string {
|
export function templateRolesTs(): string {
|
||||||
return `import type { RoleDefinition } from "@uncaged/workflow";
|
return `import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const HELLO_TEMPLATE_DESCRIPTION =
|
export const HELLO_TEMPLATE_DESCRIPTION =
|
||||||
@@ -58,7 +59,7 @@ export const greeterRole: RoleDefinition<HelloTemplateMeta["greeter"]> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateModeratorTs(): string {
|
export function templateModeratorTs(): string {
|
||||||
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow";
|
return `import { END, type Moderator, type ModeratorContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { HelloTemplateMeta } from "./roles.js";
|
import type { HelloTemplateMeta } from "./roles.js";
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ export const helloTemplateModerator: Moderator<HelloTemplateMeta> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function templateIndexTs(): string {
|
export function templateIndexTs(): string {
|
||||||
return `import type { WorkflowDefinition } from "@uncaged/workflow";
|
return `import type { WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { helloTemplateModerator } from "./moderator.js";
|
import { helloTemplateModerator } from "./moderator.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
/** Validates a single path segment for workspace / template names (no separators, not `.` / `..`). */
|
||||||
|
export function validateWorkspaceSegment(name: string): Result<void, string> {
|
||||||
|
if (name.length === 0) {
|
||||||
|
return err("workspace name must not be empty");
|
||||||
|
}
|
||||||
|
if (name === "." || name === "..") {
|
||||||
|
return err("invalid workspace name");
|
||||||
|
}
|
||||||
|
if (name.includes("/") || name.includes("\\")) {
|
||||||
|
return err("workspace name must not contain path separators");
|
||||||
|
}
|
||||||
|
return ok(undefined);
|
||||||
|
}
|
||||||
@@ -5,19 +5,7 @@ import { err, ok, type Result } from "@uncaged/workflow";
|
|||||||
|
|
||||||
import { pathExists } from "../../fs-utils.js";
|
import { pathExists } from "../../fs-utils.js";
|
||||||
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
import type { CmdInitWorkspaceSuccess } from "./types.js";
|
||||||
|
import { validateWorkspaceSegment } from "./validate.js";
|
||||||
function validateWorkspaceSegment(name: string): Result<void, string> {
|
|
||||||
if (name.length === 0) {
|
|
||||||
return err("workspace name must not be empty");
|
|
||||||
}
|
|
||||||
if (name === "." || name === "..") {
|
|
||||||
return err("invalid workspace name");
|
|
||||||
}
|
|
||||||
if (name.includes("/") || name.includes("\\")) {
|
|
||||||
return err("workspace name must not contain path separators");
|
|
||||||
}
|
|
||||||
return ok(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function rootPackageJson(workspaceName: string): string {
|
function rootPackageJson(workspaceName: string): string {
|
||||||
return `${JSON.stringify(
|
return `${JSON.stringify(
|
||||||
@@ -119,7 +107,7 @@ Init 生成的骨架:\`templates/\` 下放可复用定义,\`workflows/\` 下
|
|||||||
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
2. **编写 RoleDefinition**:为每个角色写 Zod \`schema\`,补齐 \`systemPrompt\` / \`extractPrompt\` / \`description\`。
|
||||||
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
3. **编写 Moderator**:根据 \`ctx.steps\` 与业务状态返回下一个角色名或 \`END\`。
|
||||||
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
4. **组装 WorkflowDefinition**:在模板 \`index\` 中导出 definition(以及必要的角色 / moderator 导出)。
|
||||||
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding, extract)\`(或项目约定的封装)绑定 **AgentFn** / **ExtractFn**。
|
5. **实例化**:在 workflow 包中使用 \`createWorkflow(def, binding)\`(或项目约定的封装)绑定 **AgentFn**;**ExtractFn** 由引擎从 **workflow.yaml** 注入 \`WorkflowFnOptions\`。
|
||||||
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
6. **构建**:打包为单个 **.esm.js** bundle,使用 **uncaged-workflow add** 注册。
|
||||||
|
|
||||||
## 4. 编码规范
|
## 4. 编码规范
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
|
import { createCasRoutes } from "./routes-cas.js";
|
||||||
|
import { createThreadRoutes } from "./routes-thread.js";
|
||||||
|
import { createWorkflowRoutes } from "./routes-workflow.js";
|
||||||
|
|
||||||
|
export function createApp(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.use("*", cors());
|
||||||
|
|
||||||
|
app.get("/healthz", (c) => c.json({ ok: true }));
|
||||||
|
|
||||||
|
app.route("/api/workflows", createWorkflowRoutes(storageRoot));
|
||||||
|
app.route("/api/threads", createThreadRoutes(storageRoot));
|
||||||
|
app.route("/api/cas", createCasRoutes(storageRoot));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { createApp } from "./app.js";
|
||||||
|
export { dispatchServe, startServer } from "./serve.js";
|
||||||
|
export type { ServeOptions } from "./types.js";
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createCasStore, garbageCollectCas, getGlobalCasDir } from "@uncaged/workflow";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
export function createCasRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const hashes = await cas.list();
|
||||||
|
return c.json({ hashes });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:hash", async (c) => {
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const content = await cas.get(c.req.param("hash"));
|
||||||
|
if (content === null) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ hash: c.req.param("hash"), content });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/", async (c) => {
|
||||||
|
const body = await c.req.json<{ content: string }>();
|
||||||
|
if (typeof body.content !== "string") {
|
||||||
|
return c.json({ error: "content field required" }, 400);
|
||||||
|
}
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const hash = await cas.put(body.content);
|
||||||
|
return c.json({ hash }, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/:hash", async (c) => {
|
||||||
|
const casDir = getGlobalCasDir(storageRoot);
|
||||||
|
const cas = createCasStore(casDir);
|
||||||
|
const hash = c.req.param("hash");
|
||||||
|
const content = await cas.get(hash);
|
||||||
|
if (content === null) {
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
await cas.delete(hash);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/gc", async (c) => {
|
||||||
|
const result = await garbageCollectCas(storageRoot);
|
||||||
|
if (!result.ok) {
|
||||||
|
return c.json({ error: result.error }, 500);
|
||||||
|
}
|
||||||
|
return c.json(result.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
import { readTextFileIfExists } from "../../fs-utils.js";
|
||||||
|
import {
|
||||||
|
listHistoricalThreads,
|
||||||
|
listRunningThreads,
|
||||||
|
resolveThreadDataPath,
|
||||||
|
} from "../../thread-scan.js";
|
||||||
|
|
||||||
|
export function createThreadRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const nameFilter = c.req.query("workflow") ?? null;
|
||||||
|
const rows = await listHistoricalThreads(storageRoot, nameFilter);
|
||||||
|
return c.json({ threads: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/running", async (c) => {
|
||||||
|
const rows = await listRunningThreads(storageRoot);
|
||||||
|
return c.json({ threads: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:threadId", async (c) => {
|
||||||
|
const threadId = c.req.param("threadId");
|
||||||
|
const dataPath = await resolveThreadDataPath(storageRoot, threadId);
|
||||||
|
if (dataPath === null) {
|
||||||
|
return c.json({ error: `thread not found: ${threadId}` }, 404);
|
||||||
|
}
|
||||||
|
const text = await readTextFileIfExists(dataPath);
|
||||||
|
if (text === null) {
|
||||||
|
return c.json({ error: `thread data missing: ${threadId}` }, 404);
|
||||||
|
}
|
||||||
|
const lines = text.trim().split("\n");
|
||||||
|
const records = lines.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return { raw: line };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return c.json({ threadId, records });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
getRegisteredWorkflow,
|
||||||
|
listRegisteredWorkflowNames,
|
||||||
|
readWorkflowRegistry,
|
||||||
|
} from "@uncaged/workflow";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
export function createWorkflowRoutes(storageRoot: string): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", async (c) => {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return c.json({ error: reg.error.message }, 500);
|
||||||
|
}
|
||||||
|
const names = listRegisteredWorkflowNames(reg.value);
|
||||||
|
const workflows = names.map((name) => {
|
||||||
|
const entry = reg.value.workflows[name];
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
hash: entry?.hash ?? null,
|
||||||
|
timestamp: entry?.timestamp ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return c.json({ workflows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:name", async (c) => {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return c.json({ error: reg.error.message }, 500);
|
||||||
|
}
|
||||||
|
const name = c.req.param("name");
|
||||||
|
const entry = getRegisteredWorkflow(reg.value, name);
|
||||||
|
if (entry === null) {
|
||||||
|
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ name, ...entry });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/:name/history", async (c) => {
|
||||||
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
|
if (!reg.ok) {
|
||||||
|
return c.json({ error: reg.error.message }, 500);
|
||||||
|
}
|
||||||
|
const name = c.req.param("name");
|
||||||
|
const entry = getRegisteredWorkflow(reg.value, name);
|
||||||
|
if (entry === null) {
|
||||||
|
return c.json({ error: `workflow not found: ${name}` }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ name, history: entry.history });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { err, ok, type Result } from "@uncaged/workflow";
|
||||||
|
import { serve } from "bun";
|
||||||
|
|
||||||
|
import { printCliLine } from "../../cli-output.js";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
import type { ServeOptions } from "./types.js";
|
||||||
|
|
||||||
|
export function startServer(storageRoot: string, options: ServeOptions): void {
|
||||||
|
const app = createApp(storageRoot);
|
||||||
|
|
||||||
|
const server = serve({
|
||||||
|
fetch: app.fetch,
|
||||||
|
port: options.port,
|
||||||
|
hostname: options.hostname,
|
||||||
|
});
|
||||||
|
|
||||||
|
printCliLine(`uncaged-workflow API server listening on http://${server.hostname}:${server.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePortValue(value: string | undefined): Result<number, string> {
|
||||||
|
if (value === undefined) {
|
||||||
|
return err("--port requires a value");
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) {
|
||||||
|
return err(`invalid port: ${value}`);
|
||||||
|
}
|
||||||
|
return ok(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseServeArgv(argv: string[]): Result<ServeOptions, string> {
|
||||||
|
let port = 7860;
|
||||||
|
let hostname = "127.0.0.1";
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === "--port" || arg === "-p") {
|
||||||
|
const portResult = parsePortValue(argv[i + 1]);
|
||||||
|
if (!portResult.ok) {
|
||||||
|
return portResult;
|
||||||
|
}
|
||||||
|
port = portResult.value;
|
||||||
|
i++;
|
||||||
|
} else if (arg === "--host") {
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined) {
|
||||||
|
return err("--host requires a value");
|
||||||
|
}
|
||||||
|
hostname = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ port, hostname });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dispatchServe(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
|
const parsed = parseServeArgv(argv);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
printCliLine(`error: ${parsed.error}`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer(storageRoot, parsed.value);
|
||||||
|
|
||||||
|
// Keep process alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export type ServeOptions = {
|
||||||
|
port: number;
|
||||||
|
hostname: string;
|
||||||
|
};
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
getGlobalCasDir,
|
getGlobalCasDir,
|
||||||
tryParseRoleStepRecord,
|
tryParseRoleStepRecord,
|
||||||
tryParseWorkflowResultRecord,
|
tryParseWorkflowResultRecord,
|
||||||
type WorkflowCompletion,
|
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
import { dimGreyLine, highlightLiveRole } from "../../cli-color.js";
|
||||||
import { printCliError, printCliLine } from "../../cli-output.js";
|
import { printCliError, printCliLine } from "../../cli-output.js";
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const WORKFLOW_SUBCOMMAND_TABLE: Record<string, CommandEntry> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
||||||
const { dispatchGroup, printDeprecation } = deps;
|
const { dispatchGroup } = deps;
|
||||||
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
return async function dispatchWorkflow(storageRoot: string, argv: string[]): Promise<number> {
|
||||||
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
const result = dispatchGroup("workflow", WORKFLOW_SUBCOMMAND_TABLE, storageRoot, argv);
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
@@ -150,7 +150,6 @@ export function createWorkflowDispatcher(deps: WorkflowDispatchDeps) {
|
|||||||
}
|
}
|
||||||
const sub = argv[0];
|
const sub = argv[0];
|
||||||
if (sub === "remove") {
|
if (sub === "remove") {
|
||||||
printDeprecation("workflow remove", "workflow rm");
|
|
||||||
return dispatchRemove(storageRoot, argv.slice(1));
|
return dispatchRemove(storageRoot, argv.slice(1));
|
||||||
}
|
}
|
||||||
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
printCliError(`${usageText()}\n\nerror: unknown workflow subcommand: ${sub}`);
|
||||||
|
|||||||
@@ -14,5 +14,4 @@ export type CmdAddSuccess = {
|
|||||||
|
|
||||||
export type WorkflowDispatchDeps = {
|
export type WorkflowDispatchDeps = {
|
||||||
dispatchGroup: DispatchGroupFn;
|
dispatchGroup: DispatchGroupFn;
|
||||||
printDeprecation: (oldCmd: string, newCmd: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,10 +229,3 @@ uncaged-workflow live --latest
|
|||||||
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
Bundles are immutable and identified by XXH64 hash. Re-registering a workflow with a new bundle creates a new version. Use \`workflow history\` and \`workflow rollback\` to manage versions.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Legacy compat ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** @deprecated Use formatSkillTopic("cli") instead */
|
|
||||||
export function formatSkillDoc(): string {
|
|
||||||
return formatSkillCli();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["bun-types"]
|
"types": ["bun-types"]
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../workflow" }],
|
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { ExtractContext, ExtractFn } from "@uncaged/workflow";
|
import type { ExtractContext, ExtractFn } from "@uncaged/workflow-runtime";
|
||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
import { createCursorAgent, validateCursorAgentConfig } from "../src/index.js";
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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";
|
||||||
|
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import type { AgentFn } from "@uncaged/workflow";
|
import type { AgentFn } from "@uncaged/workflow-runtime";
|
||||||
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
import { buildAgentPrompt, type SpawnCliError, spawnCli } from "@uncaged/workflow-util-agent";
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
import type { HermesAgentConfig } from "./types.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { HermesAgentConfig } from "./types.js";
|
import type { HermesAgentConfig } from "./types.js";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { mkdtempSync } from "node:fs";
|
import { mkdtempSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createCasStore, START, type ThreadContext } from "@uncaged/workflow";
|
import { createCasStore } from "@uncaged/workflow";
|
||||||
|
import { START, type ThreadContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
import { createLlmAdapter } from "../src/create-llm-adapter.js";
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "@uncaged/workflow-runtime",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||||
|
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
|
||||||
|
export type WorkflowRoleSchema = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type WorkflowRoleDescriptor = {
|
||||||
|
description: string;
|
||||||
|
schema: WorkflowRoleSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
|
||||||
|
export type WorkflowDescriptor = {
|
||||||
|
description: string;
|
||||||
|
roles: Record<string, WorkflowRoleDescriptor>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { err, ok, type Result } from "../util/index.js";
|
||||||
|
|
||||||
|
import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||||
|
|
||||||
|
export function validateWorkflowDescriptor(value: unknown): Result<WorkflowDescriptor, string> {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return err("descriptor must be a non-array object");
|
||||||
|
}
|
||||||
|
const root = value as Record<string, unknown>;
|
||||||
|
const description = root.description;
|
||||||
|
if (typeof description !== "string") {
|
||||||
|
return err("descriptor.description must be a string");
|
||||||
|
}
|
||||||
|
const rolesRaw = root.roles;
|
||||||
|
if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) {
|
||||||
|
return err("descriptor.roles must be a non-array object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles: Record<string, WorkflowRoleDescriptor> = {};
|
||||||
|
for (const [roleName, specUnknown] of Object.entries(rolesRaw)) {
|
||||||
|
if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) {
|
||||||
|
return err(`descriptor.roles.${roleName} must be a non-array object`);
|
||||||
|
}
|
||||||
|
const spec = specUnknown as Record<string, unknown>;
|
||||||
|
const roleDesc = spec.description;
|
||||||
|
if (typeof roleDesc !== "string") {
|
||||||
|
return err(`descriptor.roles.${roleName}.description must be a string`);
|
||||||
|
}
|
||||||
|
const schema = spec.schema;
|
||||||
|
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||||
|
return err(`descriptor.roles.${roleName}.schema must be a non-array object`);
|
||||||
|
}
|
||||||
|
roles[roleName] = {
|
||||||
|
description: roleDesc,
|
||||||
|
schema: schema as WorkflowRoleSchema,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ description, roles });
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type { CasStore } from "./types.js";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type CasStore = {
|
||||||
|
put(content: string): Promise<string>;
|
||||||
|
get(hash: string): Promise<string | null>;
|
||||||
|
delete(hash: string): Promise<void>;
|
||||||
|
list(): Promise<string[]>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import type { CasStore } from "../cas/types.js";
|
||||||
|
import {
|
||||||
|
type AgentBinding,
|
||||||
|
type AgentContext,
|
||||||
|
type AgentFn,
|
||||||
|
END,
|
||||||
|
type ExtractContext,
|
||||||
|
type ModeratorContext,
|
||||||
|
type ResolveRoleMetaFn,
|
||||||
|
type RoleDefinition,
|
||||||
|
type RoleMeta,
|
||||||
|
type RoleOutput,
|
||||||
|
type RoleStep,
|
||||||
|
START,
|
||||||
|
type ThreadInput,
|
||||||
|
type WorkflowCompletion,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
type WorkflowFn,
|
||||||
|
type WorkflowFnOptions,
|
||||||
|
} from "../types.js";
|
||||||
|
import { mergeRefsWithContentHash } from "../util/index.js";
|
||||||
|
|
||||||
|
function isRoleNext<M extends RoleMeta>(
|
||||||
|
next: (keyof M & string) | typeof END,
|
||||||
|
): next is keyof M & string {
|
||||||
|
return next !== END;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExtractedRefs(
|
||||||
|
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||||
|
meta: unknown,
|
||||||
|
): string[] {
|
||||||
|
const extractRefsFn = roleDef.extractRefs;
|
||||||
|
if (extractRefsFn === null || typeof extractRefsFn !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return extractRefsFn(meta as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putContentBlob(store: CasStore, raw: string): Promise<string> {
|
||||||
|
return store.put(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agentForRole(binding: AgentBinding, roleName: string): AgentFn {
|
||||||
|
const overrides = binding.overrides;
|
||||||
|
const overrideFn: AgentFn | undefined =
|
||||||
|
overrides !== null ? overrides[roleName as keyof typeof overrides] : undefined;
|
||||||
|
return overrideFn !== undefined ? overrideFn : binding.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvanceOutcome<M extends RoleMeta> =
|
||||||
|
| { kind: "complete"; completion: WorkflowCompletion }
|
||||||
|
| { kind: "yield"; output: RoleOutput; step: RoleStep<M> };
|
||||||
|
|
||||||
|
async function advanceOneRound<M extends RoleMeta>(
|
||||||
|
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||||
|
binding: AgentBinding,
|
||||||
|
resolveRoleMeta: ResolveRoleMetaFn<M>,
|
||||||
|
params: {
|
||||||
|
start: ModeratorContext<M>["start"];
|
||||||
|
steps: RoleStep<M>[];
|
||||||
|
options: WorkflowFnOptions;
|
||||||
|
},
|
||||||
|
): Promise<AdvanceOutcome<M>> {
|
||||||
|
const { start, steps, options } = params;
|
||||||
|
const modCtx: ModeratorContext<M> = {
|
||||||
|
threadId: options.threadId,
|
||||||
|
depth: options.depth,
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = def.moderator(modCtx);
|
||||||
|
if (!isRoleNext(next)) {
|
||||||
|
return {
|
||||||
|
kind: "complete",
|
||||||
|
completion: { returnCode: 0, summary: "completed: moderator returned END" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDef = def.roles[next];
|
||||||
|
if (roleDef === undefined) {
|
||||||
|
return { kind: "complete", completion: { returnCode: 1, summary: `unknown role: ${next}` } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentCtx: AgentContext<M> = {
|
||||||
|
...modCtx,
|
||||||
|
currentRole: { name: next, systemPrompt: roleDef.systemPrompt },
|
||||||
|
cas: options.cas,
|
||||||
|
};
|
||||||
|
|
||||||
|
const agent = agentForRole(binding, next);
|
||||||
|
const raw = await agent(agentCtx as unknown as AgentContext);
|
||||||
|
|
||||||
|
const extractCtx: ExtractContext<M> = {
|
||||||
|
...agentCtx,
|
||||||
|
agentContent: raw,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = await resolveRoleMeta(
|
||||||
|
roleDef as unknown as RoleDefinition<Record<string, unknown>>,
|
||||||
|
extractCtx,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentHash = await putContentBlob(options.cas, raw);
|
||||||
|
const refs = mergeRefsWithContentHash(
|
||||||
|
resolveExtractedRefs(roleDef as unknown as RoleDefinition<Record<string, unknown>>, meta),
|
||||||
|
contentHash,
|
||||||
|
);
|
||||||
|
|
||||||
|
const step = {
|
||||||
|
role: next,
|
||||||
|
contentHash,
|
||||||
|
meta,
|
||||||
|
refs,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as RoleStep<M>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "yield",
|
||||||
|
output: {
|
||||||
|
role: step.role,
|
||||||
|
contentHash: step.contentHash,
|
||||||
|
meta: step.meta,
|
||||||
|
refs: step.refs,
|
||||||
|
},
|
||||||
|
step,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds pure role definitions + moderator to runtime agents.
|
||||||
|
* Assign with `export const run = createWorkflow(def, binding)` via `@uncaged/workflow-runtime`,
|
||||||
|
* which supplies {@link ResolveRoleMetaFn}.
|
||||||
|
*/
|
||||||
|
export function createWorkflow<M extends RoleMeta>(
|
||||||
|
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
||||||
|
binding: AgentBinding,
|
||||||
|
resolveRoleMeta: ResolveRoleMetaFn<M>,
|
||||||
|
): WorkflowFn {
|
||||||
|
return async function* workflowLoop(
|
||||||
|
input: ThreadInput,
|
||||||
|
options: WorkflowFnOptions,
|
||||||
|
): AsyncGenerator<RoleOutput, WorkflowCompletion> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const start: ModeratorContext<M>["start"] = {
|
||||||
|
role: START,
|
||||||
|
content: input.prompt,
|
||||||
|
meta: { maxRounds: options.maxRounds },
|
||||||
|
timestamp: nowMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseTs = Date.now();
|
||||||
|
let steps: RoleStep<M>[] = input.steps.map((out, i) => ({
|
||||||
|
role: out.role,
|
||||||
|
contentHash: out.contentHash,
|
||||||
|
meta: out.meta,
|
||||||
|
refs: out.refs,
|
||||||
|
timestamp: baseTs + i,
|
||||||
|
})) as RoleStep<M>[];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (steps.length >= options.maxRounds) {
|
||||||
|
return {
|
||||||
|
returnCode: 0,
|
||||||
|
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await advanceOneRound(def, binding, resolveRoleMeta, {
|
||||||
|
start,
|
||||||
|
steps,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (outcome.kind === "complete") {
|
||||||
|
return outcome.completion;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield outcome.output;
|
||||||
|
steps = [...steps, outcome.step];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { createWorkflow } from "./create-workflow.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type { ExtractFn } from "./types.js";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
|
import type { ExtractContext } from "../types.js";
|
||||||
|
|
||||||
|
export type ExtractFn = <T extends Record<string, unknown>>(
|
||||||
|
schema: z.ZodType<T>,
|
||||||
|
prompt: string,
|
||||||
|
ctx: ExtractContext,
|
||||||
|
) => Promise<T>;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type {
|
||||||
|
WorkflowDescriptor,
|
||||||
|
WorkflowRoleDescriptor,
|
||||||
|
WorkflowRoleSchema,
|
||||||
|
} from "./bundle/types.js";
|
||||||
|
export { validateWorkflowDescriptor } from "./bundle/workflow-descriptor.js";
|
||||||
|
export type { CasStore } from "./cas/index.js";
|
||||||
|
export { createWorkflow } from "./engine/index.js";
|
||||||
|
export type { ExtractFn } from "./extract/index.js";
|
||||||
|
export type {
|
||||||
|
AgentBinding,
|
||||||
|
AgentContext,
|
||||||
|
AgentFn,
|
||||||
|
ExtractContext,
|
||||||
|
ExtractMode,
|
||||||
|
LlmProvider,
|
||||||
|
Moderator,
|
||||||
|
ModeratorContext,
|
||||||
|
ResolveRoleMetaFn,
|
||||||
|
RoleDefinition,
|
||||||
|
RoleMeta,
|
||||||
|
RoleOutput,
|
||||||
|
RoleStep,
|
||||||
|
StartStep,
|
||||||
|
ThreadContext,
|
||||||
|
ThreadInput,
|
||||||
|
WorkflowCompletion,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowFn,
|
||||||
|
WorkflowFnOptions,
|
||||||
|
WorkflowResult,
|
||||||
|
} from "./types.js";
|
||||||
|
export { END, START } from "./types.js";
|
||||||
|
export type { Result } from "./util/index.js";
|
||||||
|
export { err, ok } from "./util/index.js";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
|
|
||||||
import type { CasStore } from "./cas/index.js";
|
import type { CasStore } from "./cas/index.js";
|
||||||
|
import type { ExtractFn } from "./extract/types.js";
|
||||||
|
|
||||||
/** Sentinel values for automaton control flow. */
|
/** Sentinel values for automaton control flow. */
|
||||||
export const START = "__start__" as const;
|
export const START = "__start__" as const;
|
||||||
@@ -35,7 +36,7 @@ export type WorkflowCompletion = {
|
|||||||
summary: string;
|
summary: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Final thread outcome from {@link executeThread}, including Merkle thread root CAS hash. */
|
/** Final thread outcome from executeThread, including Merkle thread root CAS hash. */
|
||||||
export type WorkflowResult = WorkflowCompletion & {
|
export type WorkflowResult = WorkflowCompletion & {
|
||||||
rootHash: string;
|
rootHash: string;
|
||||||
};
|
};
|
||||||
@@ -54,6 +55,10 @@ export type WorkflowFnOptions = {
|
|||||||
depth: number;
|
depth: number;
|
||||||
/** Global CAS store for Merkle content blobs (role step bodies). */
|
/** Global CAS store for Merkle content blobs (role step bodies). */
|
||||||
cas: CasStore;
|
cas: CasStore;
|
||||||
|
/** Structured meta extraction; resolved from workflow.yaml `extract` scene by the engine. */
|
||||||
|
extract: ExtractFn;
|
||||||
|
/** Provider for `extractMode: "react"` roles; same backing config as `extract`. */
|
||||||
|
llmProvider: LlmProvider | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
|
/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */
|
||||||
@@ -110,10 +115,10 @@ export type ThreadContext<M extends RoleMeta = RoleMeta> = AgentContext<M>;
|
|||||||
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
/** Raw string output from an LLM/CLI adapter; meta is extracted by the engine. */
|
||||||
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
export type AgentFn = (ctx: AgentContext) => Promise<string>;
|
||||||
|
|
||||||
/** Runtime agent assignment (optional per-role overrides). */
|
/** Runtime agent assignment (explicit null when no per-role overrides). */
|
||||||
export type AgentBinding = {
|
export type AgentBinding = {
|
||||||
agent: AgentFn;
|
agent: AgentFn;
|
||||||
overrides?: Partial<Record<string, AgentFn>>;
|
overrides: Partial<Record<string, AgentFn>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Role wiring: prompts, schema, and human-readable description. */
|
/** Role wiring: prompts, schema, and human-readable description. */
|
||||||
@@ -143,3 +148,10 @@ export type WorkflowDefinition<M extends RoleMeta> = {
|
|||||||
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
roles: { [K in keyof M & string]: RoleDefinition<M[K]> };
|
||||||
moderator: Moderator<M>;
|
moderator: Moderator<M>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Engine-injected meta extraction for workflow loops (single + react modes). */
|
||||||
|
export type ResolveRoleMetaFn<M extends RoleMeta = RoleMeta> = (
|
||||||
|
roleDef: RoleDefinition<Record<string, unknown>>,
|
||||||
|
extractCtx: ExtractContext<M>,
|
||||||
|
options: WorkflowFnOptions,
|
||||||
|
) => Promise<Record<string, unknown>>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { mergeRefsWithContentHash } from "./refs-field.js";
|
||||||
|
export { err, ok } from "./result.js";
|
||||||
|
export type { Result } from "./types.js";
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Append `contentHash` to `refs` when not already present (dedupe by first occurrence order). */
|
||||||
|
export function mergeRefsWithContentHash(refs: string[], contentHash: string): string[] {
|
||||||
|
const out = [...refs];
|
||||||
|
if (!out.includes(contentHash)) {
|
||||||
|
out.push(contentHash);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Result } from "./types.js";
|
||||||
|
|
||||||
|
export function ok<T>(value: T): Result<T, never> {
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function err<E>(error: E): Result<never, E> {
|
||||||
|
return { ok: false, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
import { buildDevelopDescriptor } from "../src/descriptor.js";
|
||||||
import { developModerator } from "../src/index.js";
|
import { developModerator } from "../src/index.js";
|
||||||
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
import type { CommitterMeta, PlannerMeta } from "../src/roles/index.js";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-template-develop",
|
"name": "@uncaged/workflow-template-develop",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import {
|
import { createWorkflow } from "@uncaged/workflow";
|
||||||
type AgentBinding,
|
import type { AgentBinding, WorkflowDefinition, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||||
createWorkflow,
|
|
||||||
type ExtractFn,
|
|
||||||
type LlmProvider,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { developModerator } from "./moderator.js";
|
import { developModerator } from "./moderator.js";
|
||||||
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
|
||||||
@@ -43,10 +37,6 @@ export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
|
|||||||
moderator: developModerator,
|
moderator: developModerator,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDevelopRun(
|
export function createDevelopRun(binding: AgentBinding): WorkflowFn {
|
||||||
binding: AgentBinding,
|
return createWorkflow(developWorkflowDefinition, binding);
|
||||||
extract: ExtractFn,
|
|
||||||
llmProvider: LlmProvider | null,
|
|
||||||
): WorkflowFn {
|
|
||||||
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
|
import type { Moderator, ModeratorContext } from "@uncaged/workflow-runtime";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { DevelopMeta } from "./roles.js";
|
import type { DevelopMeta } from "./roles.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
import { type CoderMeta, coderRole } from "./roles/coder.js";
|
||||||
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
import { type CommitterMeta, committerRole } from "./roles/committer.js";
|
||||||
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
import { type PlannerMeta, plannerRole } from "./roles/planner.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const coderMetaSchema = z.object({
|
export const coderMetaSchema = z.object({
|
||||||
@@ -15,7 +15,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
|
|||||||
|
|
||||||
## Reading phase details
|
## Reading phase details
|
||||||
|
|
||||||
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <THREAD_ID> <HASH>\`.
|
Each planner phase has a content-hash and title. Read full details with \`uncaged-workflow cas get <HASH>\`.
|
||||||
|
|
||||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const committerMetaSchema = z.discriminatedUnion("status", [
|
export const committerMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const phaseSchema = z.object({
|
export const phaseSchema = z.object({
|
||||||
@@ -18,7 +18,7 @@ Run \`uncaged-workflow skill develop\` for thread ID lookup, CAS commands, and m
|
|||||||
|
|
||||||
## Storing phase details — MANDATORY
|
## Storing phase details — MANDATORY
|
||||||
|
|
||||||
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put <THREAD_ID> '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
For each phase, store its full detail text in CAS via \`uncaged-workflow cas put '<content>'\`. The command prints a content-hash — use that as the phase identifier.
|
||||||
|
|
||||||
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
The thread ID (26-char Crockford Base32) appears in the first message. If unsure, run \`uncaged-workflow thread list\`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
export const reviewerMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const testerMetaSchema = z.discriminatedUnion("status", [
|
export const testerMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { createCasStore, createExtract } from "@uncaged/workflow";
|
||||||
import {
|
import {
|
||||||
createCasStore,
|
|
||||||
createExtract,
|
|
||||||
END,
|
END,
|
||||||
type ModeratorContext,
|
type ModeratorContext,
|
||||||
type RoleStep,
|
type RoleStep,
|
||||||
START,
|
START,
|
||||||
validateWorkflowDescriptor,
|
validateWorkflowDescriptor,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow-runtime";
|
||||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||||
import type { DeveloperMeta } from "../src/developer.js";
|
import type { DeveloperMeta } from "../src/developer.js";
|
||||||
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
import { createSolveIssueRun, solveIssueModerator } from "../src/index.js";
|
||||||
@@ -250,17 +249,20 @@ describe("createSolveIssueRun", () => {
|
|||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
// Override developer so the test does not spin up a child workflow.
|
// Override developer so the test does not spin up a child workflow.
|
||||||
const run = createSolveIssueRun(
|
const run = createSolveIssueRun({
|
||||||
{
|
|
||||||
agent: async () => "",
|
agent: async () => "",
|
||||||
overrides: { developer: async () => "stub-root-hash" },
|
overrides: { developer: async () => "stub-root-hash" },
|
||||||
},
|
});
|
||||||
stubExtract,
|
|
||||||
stubLlmProvider,
|
|
||||||
);
|
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
{
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
cas,
|
||||||
|
extract: stubExtract,
|
||||||
|
llmProvider: stubLlmProvider,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const first = await gen.next();
|
const first = await gen.next();
|
||||||
expect(first.done).toBe(false);
|
expect(first.done).toBe(false);
|
||||||
@@ -294,8 +296,7 @@ describe("createSolveIssueRun", () => {
|
|||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const run = createSolveIssueRun(
|
const run = createSolveIssueRun({
|
||||||
{
|
|
||||||
agent: async () => {
|
agent: async () => {
|
||||||
calls.push("default");
|
calls.push("default");
|
||||||
return "";
|
return "";
|
||||||
@@ -314,13 +315,17 @@ describe("createSolveIssueRun", () => {
|
|||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
stubExtract,
|
|
||||||
stubLlmProvider,
|
|
||||||
);
|
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
{
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
cas,
|
||||||
|
extract: stubExtract,
|
||||||
|
llmProvider: stubLlmProvider,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
await gen.next();
|
await gen.next();
|
||||||
expect(calls).toEqual(["preparer"]);
|
expect(calls).toEqual(["preparer"]);
|
||||||
@@ -353,8 +358,7 @@ describe("createSolveIssueRun", () => {
|
|||||||
const cas = createCasStore(casDir);
|
const cas = createCasStore(casDir);
|
||||||
|
|
||||||
let developerInvocations = 0;
|
let developerInvocations = 0;
|
||||||
const run = createSolveIssueRun(
|
const run = createSolveIssueRun({
|
||||||
{
|
|
||||||
agent: async () => "",
|
agent: async () => "",
|
||||||
overrides: {
|
overrides: {
|
||||||
developer: async () => {
|
developer: async () => {
|
||||||
@@ -362,13 +366,17 @@ describe("createSolveIssueRun", () => {
|
|||||||
return "stub-root-hash";
|
return "stub-root-hash";
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
stubExtract,
|
|
||||||
stubLlmProvider,
|
|
||||||
);
|
|
||||||
const gen = run(
|
const gen = run(
|
||||||
{ prompt: "task", steps: [] },
|
{ prompt: "task", steps: [] },
|
||||||
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
|
{
|
||||||
|
threadId: "01TEST000000000000000000TR",
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
cas,
|
||||||
|
extract: stubExtract,
|
||||||
|
llmProvider: stubLlmProvider,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
// preparer
|
// preparer
|
||||||
await gen.next();
|
await gen.next();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-template-solve-issue",
|
"name": "@uncaged/workflow-template-solve-issue",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*",
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const developerMetaSchema = z.object({
|
export const developerMetaSchema = z.object({
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import {
|
import { createWorkflow, workflowAsAgent } from "@uncaged/workflow";
|
||||||
type AgentBinding,
|
import type { AgentBinding, WorkflowDefinition, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||||
createWorkflow,
|
|
||||||
type ExtractFn,
|
|
||||||
type LlmProvider,
|
|
||||||
type WorkflowDefinition,
|
|
||||||
type WorkflowFn,
|
|
||||||
workflowAsAgent,
|
|
||||||
} from "@uncaged/workflow";
|
|
||||||
|
|
||||||
import { solveIssueModerator } from "./moderator.js";
|
import { solveIssueModerator } from "./moderator.js";
|
||||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js";
|
||||||
@@ -46,11 +39,7 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
|
|||||||
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
|
* {@link workflowAsAgent}; if the caller supplies their own `developer` override in
|
||||||
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
|
* `binding.overrides`, it takes precedence so tests and custom hosts can stub it.
|
||||||
*/
|
*/
|
||||||
export function createSolveIssueRun(
|
export function createSolveIssueRun(binding: AgentBinding): WorkflowFn {
|
||||||
binding: AgentBinding,
|
|
||||||
extract: ExtractFn,
|
|
||||||
llmProvider: LlmProvider | null,
|
|
||||||
): WorkflowFn {
|
|
||||||
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
|
const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop");
|
||||||
const mergedBinding: AgentBinding = {
|
const mergedBinding: AgentBinding = {
|
||||||
agent: binding.agent,
|
agent: binding.agent,
|
||||||
@@ -59,5 +48,5 @@ export function createSolveIssueRun(
|
|||||||
developer: developerOverride,
|
developer: developerOverride,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider);
|
return createWorkflow(solveIssueWorkflowDefinition, mergedBinding);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Moderator } from "@uncaged/workflow";
|
import type { Moderator } from "@uncaged/workflow-runtime";
|
||||||
import { END } from "@uncaged/workflow";
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import type { SolveIssueMeta } from "./roles.js";
|
import type { SolveIssueMeta } from "./roles.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import { type DeveloperMeta, developerRole } from "./developer.js";
|
import { type DeveloperMeta, developerRole } from "./developer.js";
|
||||||
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
import { type PreparerMeta, preparerRole } from "./roles/preparer.js";
|
||||||
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
import { type SubmitterMeta, submitterRole } from "./roles/submitter.js";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
const toolchainSchema = z.object({
|
const toolchainSchema = z.object({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RoleDefinition } from "@uncaged/workflow";
|
import type { RoleDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
export const submitterMetaSchema = z.discriminatedUnion("status", [
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { createCasStore, putContentMerkleNode, START, type ThreadContext } from "@uncaged/workflow";
|
import { createCasStore, putContentMerkleNode } from "@uncaged/workflow";
|
||||||
|
import { START, type ThreadContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
import { buildAgentPrompt } from "../src/index.js";
|
import { buildAgentPrompt } from "../src/index.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow-util-agent",
|
"name": "@uncaged/workflow-util-agent",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@uncaged/workflow": "workspace:*"
|
"@uncaged/workflow": "workspace:*",
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentContext } from "@uncaged/workflow";
|
|
||||||
import { getContentMerklePayload } from "@uncaged/workflow";
|
import { getContentMerklePayload } from "@uncaged/workflow";
|
||||||
|
import type { AgentContext } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
|
async function resolveStepText(ctx: AgentContext, contentHash: string): Promise<string> {
|
||||||
const text = await getContentMerklePayload(ctx.cas, contentHash);
|
const text = await getContentMerklePayload(ctx.cas, contentHash);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
import { err, ok, type Result } from "@uncaged/workflow";
|
import { err, ok, type Result } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
export type SpawnCliError =
|
export type SpawnCliError =
|
||||||
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
|
| { kind: "non_zero_exit"; exitCode: number | null; stdout: string; stderr: string }
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { createWorkflow, readWorkflowRegistry, executeThread } from "@uncaged/wo
|
|||||||
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
|
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
|
||||||
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
|
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
|
||||||
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
|
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
|
||||||
| **CAS** | `createCasStore`, `createThreadCas`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
|
| **CAS** | `createCasStore`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
|
||||||
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
|
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
|
||||||
| **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` |
|
| **Extract / LLM tools** | `llmExtract`, `reactExtract`, `createExtract`, `getExtractProvider` |
|
||||||
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
|
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||||
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
|
|
||||||
describe("buildDescriptor", () => {
|
describe("buildDescriptor", () => {
|
||||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ export const run = async function* (_input, options) {
|
|||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("allows static import of @uncaged/workflow-runtime", () => {
|
||||||
|
const source = `${minimalDescriptor}import { createWorkflow } from "@uncaged/workflow-runtime";
|
||||||
|
import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
|
|
||||||
|
export const run = createWorkflow({ description: "x", roles: {}, moderator: () => "END" }, {});
|
||||||
|
`;
|
||||||
|
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects wrong filename suffix", () => {
|
test("rejects wrong filename suffix", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.js",
|
filePath: "/tmp/w.js",
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import {
|
import {
|
||||||
createContentMerkleNode,
|
createContentMerkleNode,
|
||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
} from "../src/cas/merkle.js";
|
} from "../src/cas/merkle.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { createExtract } from "../src/extract/extract-fn.js";
|
|
||||||
import { END, type LlmProvider } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
|
|
||||||
const plannerMetaSchema = z.object({
|
const plannerMetaSchema = z.object({
|
||||||
@@ -82,11 +80,112 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const demoExtract = createExtract({
|
const EXTRACT_REGISTRY_YAML = `config:
|
||||||
baseUrl: "http://127.0.0.1:9",
|
maxDepth: 3
|
||||||
apiKey: "test",
|
providers:
|
||||||
model: "test",
|
stub:
|
||||||
});
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
default: stub/model
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
|
||||||
|
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPERVISOR_INTERVAL_REGISTRY_YAML = `config:
|
||||||
|
maxDepth: 3
|
||||||
|
supervisorInterval: 2
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
extract: stub/model
|
||||||
|
supervisor: stub/supervisor-cheap
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML = `config:
|
||||||
|
maxDepth: 3
|
||||||
|
supervisorInterval: 10
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
extract: stub/model
|
||||||
|
supervisor: stub/supervisor-cheap
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<void> {
|
||||||
|
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract rounds use tool_calls; supervisor uses plain `content` (no tools). */
|
||||||
|
function installMockExtractThenSupervisor(params: {
|
||||||
|
extractArgs: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
supervisorContent: string;
|
||||||
|
onSupervisorCall?: () => void;
|
||||||
|
}): () => void {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
let extractI = 0;
|
||||||
|
const mockFetch = async (
|
||||||
|
_input: Parameters<typeof fetch>[0],
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
const body = init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {};
|
||||||
|
const tools = body.tools;
|
||||||
|
const hasTools = Array.isArray(tools) && tools.length > 0;
|
||||||
|
if (hasTools) {
|
||||||
|
const args =
|
||||||
|
params.extractArgs[extractI] ?? params.extractArgs[params.extractArgs.length - 1];
|
||||||
|
if (args === undefined) {
|
||||||
|
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
|
||||||
|
}
|
||||||
|
extractI += 1;
|
||||||
|
const firstTool = tools[0] as Record<string, unknown>;
|
||||||
|
const fn = firstTool.function as Record<string, unknown> | undefined;
|
||||||
|
const toolName = typeof fn?.name === "string" ? fn.name : "extract";
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: JSON.stringify(args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
params.onSupervisorCall?.();
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [{ message: { content: params.supervisorContent } }],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
globalThis.fetch = Object.assign(mockFetch, {
|
||||||
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
|
}) as typeof fetch;
|
||||||
|
return () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const demoWorkflow = createWorkflow<DemoMeta>(
|
const demoWorkflow = createWorkflow<DemoMeta>(
|
||||||
{
|
{
|
||||||
@@ -125,8 +224,6 @@ const demoWorkflow = createWorkflow<DemoMeta>(
|
|||||||
coder: async () => "code-body",
|
coder: async () => "code-body",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
demoExtract,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("executeThread", () => {
|
describe("executeThread", () => {
|
||||||
@@ -150,6 +247,7 @@ describe("executeThread", () => {
|
|||||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeExtractRegistryConfig(root);
|
||||||
const cas = createCasStore(join(root, "cas"));
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
@@ -166,6 +264,7 @@ describe("executeThread", () => {
|
|||||||
awaitAfterEachYield: async () => {},
|
awaitAfterEachYield: async () => {},
|
||||||
forkSourceThreadId: null,
|
forkSourceThreadId: null,
|
||||||
prefilledDiskSteps: null,
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
@@ -258,6 +357,7 @@ describe("executeThread", () => {
|
|||||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeExtractRegistryConfig(root);
|
||||||
const cas = createCasStore(join(root, "cas"));
|
const cas = createCasStore(join(root, "cas"));
|
||||||
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
|
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
|
||||||
|
|
||||||
@@ -295,6 +395,7 @@ describe("executeThread", () => {
|
|||||||
timestamp: histTs,
|
timestamp: histTs,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
@@ -354,6 +455,7 @@ describe("executeThread", () => {
|
|||||||
awaitAfterEachYield: async () => {},
|
awaitAfterEachYield: async () => {},
|
||||||
forkSourceThreadId: null,
|
forkSourceThreadId: null,
|
||||||
prefilledDiskSteps: null,
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
@@ -391,6 +493,7 @@ describe("executeThread", () => {
|
|||||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeExtractRegistryConfig(root);
|
||||||
const cas = createCasStore(join(root, "cas"));
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
@@ -407,6 +510,7 @@ describe("executeThread", () => {
|
|||||||
awaitAfterEachYield: async () => {},
|
awaitAfterEachYield: async () => {},
|
||||||
forkSourceThreadId: null,
|
forkSourceThreadId: null,
|
||||||
prefilledDiskSteps: null,
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
@@ -549,9 +653,6 @@ describe("executeThread", () => {
|
|||||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||||
) as typeof fetch;
|
) as typeof fetch;
|
||||||
|
|
||||||
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
|
|
||||||
const extractFn = createExtract(llm);
|
|
||||||
|
|
||||||
const dagWorkflow = createWorkflow<DagDemoMeta>(
|
const dagWorkflow = createWorkflow<DagDemoMeta>(
|
||||||
{
|
{
|
||||||
roles: {
|
roles: {
|
||||||
@@ -567,9 +668,7 @@ describe("executeThread", () => {
|
|||||||
},
|
},
|
||||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||||
},
|
},
|
||||||
{ agent: async () => dagRootHash },
|
{ agent: async () => dagRootHash, overrides: null },
|
||||||
extractFn,
|
|
||||||
llm,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
@@ -577,6 +676,7 @@ describe("executeThread", () => {
|
|||||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeExtractRegistryConfig(root);
|
||||||
|
|
||||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
@@ -592,6 +692,7 @@ describe("executeThread", () => {
|
|||||||
awaitAfterEachYield: async () => {},
|
awaitAfterEachYield: async () => {},
|
||||||
forkSourceThreadId: null,
|
forkSourceThreadId: null,
|
||||||
prefilledDiskSteps: null,
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
@@ -613,4 +714,102 @@ describe("executeThread", () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("supervisor stops thread when interval elapses and model returns stop", async () => {
|
||||||
|
restoreFetch = installMockExtractThenSupervisor({
|
||||||
|
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
|
||||||
|
supervisorContent: "stop",
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-stop-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeRegistryYaml(root, SUPERVISOR_INTERVAL_REGISTRY_YAML);
|
||||||
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
demoWorkflow,
|
||||||
|
"demo-flow",
|
||||||
|
{ prompt: "supervisor-stop-case", steps: [] },
|
||||||
|
{
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
signal: ac.signal,
|
||||||
|
awaitAfterEachYield: async () => {},
|
||||||
|
forkSourceThreadId: null,
|
||||||
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
|
},
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
expect(result.summary).toBe("completed: supervisor stopped thread");
|
||||||
|
|
||||||
|
const dataText = await readFile(dataPath, "utf8");
|
||||||
|
const lines = dataText
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => l !== "");
|
||||||
|
expect(lines.length).toBe(3);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("supervisor is not invoked before supervisorInterval rounds", async () => {
|
||||||
|
let supervisorCalls = 0;
|
||||||
|
restoreFetch = installMockExtractThenSupervisor({
|
||||||
|
extractArgs: [{ plan: "do-it", files: ["a.ts"] }, { diff: "+ok" }],
|
||||||
|
supervisorContent: "stop",
|
||||||
|
onSupervisorCall: () => {
|
||||||
|
supervisorCalls += 1;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "wf-engine-sup-skip-"));
|
||||||
|
try {
|
||||||
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
|
const hash = "C9NMV6V2TQT81";
|
||||||
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeRegistryYaml(root, SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML);
|
||||||
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
const result = await executeThread(
|
||||||
|
demoWorkflow,
|
||||||
|
"demo-flow",
|
||||||
|
{ prompt: "no-supervisor-yet", steps: [] },
|
||||||
|
{
|
||||||
|
maxRounds: 20,
|
||||||
|
depth: 0,
|
||||||
|
signal: ac.signal,
|
||||||
|
awaitAfterEachYield: async () => {},
|
||||||
|
forkSourceThreadId: null,
|
||||||
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
|
},
|
||||||
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(supervisorCalls).toBe(0);
|
||||||
|
expect(result.returnCode).toBe(0);
|
||||||
|
expect(result.summary).toBe("completed: moderator returned END");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { getExtractProvider } from "../src/extract-provider.js";
|
|
||||||
|
|
||||||
describe("getExtractProvider", () => {
|
|
||||||
test("returns provider when config.extract is present", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
|
|
||||||
try {
|
|
||||||
await mkdir(root, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(root, "workflow.yaml"),
|
|
||||||
`config:
|
|
||||||
maxDepth: 3
|
|
||||||
extract:
|
|
||||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
|
||||||
model: qwen-plus
|
|
||||||
apiKey: literal-key
|
|
||||||
workflows: {}
|
|
||||||
`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const r = await getExtractProvider(root);
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
if (!r.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
|
||||||
expect(r.value.model).toBe("qwen-plus");
|
|
||||||
expect(r.value.apiKey).toBe("literal-key");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("errs when registry has no config section", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
|
|
||||||
try {
|
|
||||||
await mkdir(root, { recursive: true });
|
|
||||||
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
|
|
||||||
const r = await getExtractProvider(root);
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (r.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(r.error).toContain("no global config");
|
|
||||||
} finally {
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolves apiKey from env at registry read time", async () => {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
|
|
||||||
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
|
||||||
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
|
|
||||||
try {
|
|
||||||
await mkdir(root, { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
join(root, "workflow.yaml"),
|
|
||||||
`config:
|
|
||||||
maxDepth: 1
|
|
||||||
extract:
|
|
||||||
baseUrl: https://example.com
|
|
||||||
model: m
|
|
||||||
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
|
|
||||||
workflows: {}
|
|
||||||
`,
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const r = await getExtractProvider(root);
|
|
||||||
expect(r.ok).toBe(true);
|
|
||||||
if (!r.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
expect(r.value.apiKey).toBe("resolved-secret");
|
|
||||||
} finally {
|
|
||||||
if (prev === undefined) {
|
|
||||||
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
|
|
||||||
} else {
|
|
||||||
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
|
|
||||||
}
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,12 +2,11 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||||
import { reactExtract } from "../src/extract/react-extract.js";
|
import { reactExtract } from "../src/extract/react-extract.js";
|
||||||
import type { LlmProvider } from "../src/types.js";
|
|
||||||
|
|
||||||
const metaSchema = z.object({ seen: z.string() });
|
const metaSchema = z.object({ seen: z.string() });
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
||||||
import { createExtract } from "../src/extract/extract-fn.js";
|
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
|
|
||||||
const phaseSchema = z.object({
|
const phaseSchema = z.object({
|
||||||
@@ -76,11 +74,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const refsDemoExtract = createExtract({
|
const EXTRACT_REGISTRY_YAML = `config:
|
||||||
baseUrl: "http://127.0.0.1:9",
|
maxDepth: 3
|
||||||
apiKey: "test",
|
providers:
|
||||||
model: "test",
|
stub:
|
||||||
});
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
default: stub/model
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||||
{
|
{
|
||||||
@@ -98,9 +101,8 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
agent: async () => "plan-output",
|
agent: async () => "plan-output",
|
||||||
|
overrides: null,
|
||||||
},
|
},
|
||||||
refsDemoExtract,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
describe("RoleStep refs tracking", () => {
|
describe("RoleStep refs tracking", () => {
|
||||||
@@ -142,6 +144,7 @@ describe("RoleStep refs tracking", () => {
|
|||||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||||
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
|
||||||
await mkdir(join(root, "logs", hash), { recursive: true });
|
await mkdir(join(root, "logs", hash), { recursive: true });
|
||||||
|
await writeFile(join(root, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
||||||
const cas = createCasStore(join(root, "cas"));
|
const cas = createCasStore(join(root, "cas"));
|
||||||
|
|
||||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||||
@@ -158,6 +161,7 @@ describe("RoleStep refs tracking", () => {
|
|||||||
awaitAfterEachYield: async () => {},
|
awaitAfterEachYield: async () => {},
|
||||||
forkSourceThreadId: null,
|
forkSourceThreadId: null,
|
||||||
prefilledDiskSteps: null,
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -105,10 +105,13 @@ describe("workflow registry", () => {
|
|||||||
const yaml = `
|
const yaml = `
|
||||||
config:
|
config:
|
||||||
maxDepth: 3
|
maxDepth: 3
|
||||||
extract:
|
providers:
|
||||||
|
dashscope:
|
||||||
baseUrl: https://example.com/v1
|
baseUrl: https://example.com/v1
|
||||||
model: qwen-plus
|
|
||||||
apiKey: secret-key
|
apiKey: secret-key
|
||||||
|
models:
|
||||||
|
default: dashscope/qwen-turbo
|
||||||
|
extract: dashscope/qwen-plus
|
||||||
workflows:
|
workflows:
|
||||||
solve-issue:
|
solve-issue:
|
||||||
hash: SPVR4BDMSGC1W
|
hash: SPVR4BDMSGC1W
|
||||||
@@ -125,9 +128,69 @@ workflows:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(r.value.config.maxDepth).toBe(3);
|
expect(r.value.config.maxDepth).toBe(3);
|
||||||
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
|
expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
|
||||||
expect(r.value.config.extract.model).toBe("qwen-plus");
|
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
|
||||||
expect(r.value.config.extract.apiKey).toBe("secret-key");
|
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
|
||||||
|
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
|
||||||
|
expect(r.value.config.supervisorInterval).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults supervisorInterval to 3 when omitted", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 0
|
||||||
|
providers:
|
||||||
|
p:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
apiKey: k
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok || r.value.config === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config.supervisorInterval).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses explicit supervisorInterval", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 0
|
||||||
|
supervisorInterval: 7
|
||||||
|
providers:
|
||||||
|
p:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
apiKey: k
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok || r.value.config === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.config.supervisorInterval).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse errors when supervisorInterval is negative", () => {
|
||||||
|
const yaml = `
|
||||||
|
config:
|
||||||
|
maxDepth: 0
|
||||||
|
supervisorInterval: -1
|
||||||
|
providers:
|
||||||
|
p:
|
||||||
|
baseUrl: https://example.com
|
||||||
|
apiKey: k
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses config apiKey env: prefix from process.env", () => {
|
test("parses config apiKey env: prefix from process.env", () => {
|
||||||
@@ -137,10 +200,13 @@ workflows:
|
|||||||
const yaml = `
|
const yaml = `
|
||||||
config:
|
config:
|
||||||
maxDepth: 1
|
maxDepth: 1
|
||||||
extract:
|
providers:
|
||||||
|
dashscope:
|
||||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
model: qwen-plus
|
|
||||||
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||||
|
models:
|
||||||
|
default: dashscope/qwen-plus
|
||||||
|
extract: dashscope/qwen-plus
|
||||||
workflows: {}
|
workflows: {}
|
||||||
`;
|
`;
|
||||||
const r = parseWorkflowRegistryYaml(yaml);
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
@@ -148,7 +214,7 @@ workflows: {}
|
|||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(r.value.config?.extract.apiKey).toBe("from-env");
|
expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) {
|
if (prev === undefined) {
|
||||||
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
||||||
@@ -165,10 +231,12 @@ workflows: {}
|
|||||||
const yaml = `
|
const yaml = `
|
||||||
config:
|
config:
|
||||||
maxDepth: 1
|
maxDepth: 1
|
||||||
extract:
|
providers:
|
||||||
|
p:
|
||||||
baseUrl: https://example.com
|
baseUrl: https://example.com
|
||||||
model: m
|
|
||||||
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||||
|
models:
|
||||||
|
default: p/m
|
||||||
workflows: {}
|
workflows: {}
|
||||||
`;
|
`;
|
||||||
const r = parseWorkflowRegistryYaml(yaml);
|
const r = parseWorkflowRegistryYaml(yaml);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { resolveModel } from "../src/config/resolve-model.js";
|
||||||
|
import type { WorkflowConfig } from "../src/registry/index.js";
|
||||||
|
|
||||||
|
function sampleConfig(): WorkflowConfig {
|
||||||
|
return {
|
||||||
|
maxDepth: 3,
|
||||||
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
dashscope: {
|
||||||
|
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
apiKey: "secret",
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
baseUrl: "https://other.example/v1",
|
||||||
|
apiKey: "k2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
default: "dashscope/qwen-plus",
|
||||||
|
extract: "other/foo/bar-model",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveModel", () => {
|
||||||
|
test("uses explicit scene mapping", () => {
|
||||||
|
const config = sampleConfig();
|
||||||
|
const r = resolveModel(config, "extract");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.baseUrl).toBe("https://other.example/v1");
|
||||||
|
expect(r.value.apiKey).toBe("k2");
|
||||||
|
expect(r.value.model).toBe("foo/bar-model");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to models.default when scene is missing", () => {
|
||||||
|
const config = sampleConfig();
|
||||||
|
const r = resolveModel(config, "unknown-scene");
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value.model).toBe("qwen-plus");
|
||||||
|
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errs when scene missing and no default", () => {
|
||||||
|
const config: WorkflowConfig = {
|
||||||
|
maxDepth: 1,
|
||||||
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
p: { baseUrl: "https://x", apiKey: "k" },
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
extract: "p/m",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = resolveModel(config, "other");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.error).toContain("no model mapping");
|
||||||
|
expect(r.error).toContain("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errs when provider is unknown", () => {
|
||||||
|
const config: WorkflowConfig = {
|
||||||
|
maxDepth: 1,
|
||||||
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
p: { baseUrl: "https://x", apiKey: "k" },
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
default: "missing/m",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = resolveModel(config, "any");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
if (r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.error).toContain("unknown provider");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("errs on invalid model reference shape", () => {
|
||||||
|
const config: WorkflowConfig = {
|
||||||
|
maxDepth: 1,
|
||||||
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
p: { baseUrl: "https://x", apiKey: "k" },
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
default: "no-slash-model",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = resolveModel(config, "x");
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
import { parseSupervisorDecisionText, runSupervisor } from "../src/engine/supervisor.js";
|
||||||
|
import type { WorkflowConfig } from "../src/registry/index.js";
|
||||||
|
import type { LogFn } from "../src/util/index.js";
|
||||||
|
|
||||||
|
const noopLogger: LogFn = () => {};
|
||||||
|
|
||||||
|
function supervisorOnlyConfig(): WorkflowConfig {
|
||||||
|
return {
|
||||||
|
maxDepth: 3,
|
||||||
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
extract: "stub/extract-model",
|
||||||
|
supervisor: "stub/supervisor-model",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("parseSupervisorDecisionText", () => {
|
||||||
|
test("reads continue and stop case-insensitively", () => {
|
||||||
|
expect(parseSupervisorDecisionText("continue")).toBe("continue");
|
||||||
|
expect(parseSupervisorDecisionText("CONTINUE")).toBe("continue");
|
||||||
|
expect(parseSupervisorDecisionText("stop")).toBe("stop");
|
||||||
|
expect(parseSupervisorDecisionText("STOP.")).toBe("stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds token inside a sentence", () => {
|
||||||
|
expect(parseSupervisorDecisionText("Answer: continue")).toBe("continue");
|
||||||
|
expect(parseSupervisorDecisionText("I recommend stop now")).toBe("stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("when both appear, earlier token wins", () => {
|
||||||
|
expect(parseSupervisorDecisionText("continue then stop")).toBe("continue");
|
||||||
|
expect(parseSupervisorDecisionText("stop then continue")).toBe("stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults to continue when unclear", () => {
|
||||||
|
expect(parseSupervisorDecisionText("maybe later")).toBe("continue");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runSupervisor", () => {
|
||||||
|
let restoreFetch: (() => void) | null = null;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreFetch?.();
|
||||||
|
restoreFetch = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns continue when supervisor model cannot be resolved (no fetch)", async () => {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
restoreFetch = () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
globalThis.fetch = Object.assign(
|
||||||
|
async () => {
|
||||||
|
throw new Error("fetch should not run when supervisor is not configured");
|
||||||
|
},
|
||||||
|
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||||
|
) as typeof fetch;
|
||||||
|
|
||||||
|
const config: WorkflowConfig = {
|
||||||
|
maxDepth: 1,
|
||||||
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
stub: { baseUrl: "http://127.0.0.1:9/v1", apiKey: "k" },
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
extract: "stub/m",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = await runSupervisor({
|
||||||
|
config,
|
||||||
|
prompt: "task",
|
||||||
|
recentSteps: [{ role: "planner", summary: "{}" }],
|
||||||
|
logger: noopLogger,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value).toBe("continue");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns stop from chat/completions assistant content", async () => {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
restoreFetch = () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
globalThis.fetch = Object.assign(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
choices: [{ message: { content: "stop" } }],
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||||
|
),
|
||||||
|
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||||
|
) as typeof fetch;
|
||||||
|
|
||||||
|
const r = await runSupervisor({
|
||||||
|
config: supervisorOnlyConfig(),
|
||||||
|
prompt: "do X",
|
||||||
|
recentSteps: [{ role: "a", summary: "{}" }],
|
||||||
|
logger: noopLogger,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
if (!r.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(r.value).toBe("stop");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns err on invalid JSON body", async () => {
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
restoreFetch = () => {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
};
|
||||||
|
globalThis.fetch = Object.assign(async () => new Response("not-json", { status: 200 }), {
|
||||||
|
preconnect: origFetch.preconnect.bind(origFetch),
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
const r = await runSupervisor({
|
||||||
|
config: supervisorOnlyConfig(),
|
||||||
|
prompt: "p",
|
||||||
|
recentSteps: [],
|
||||||
|
logger: noopLogger,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,17 @@ import { createCasStore } from "../src/cas/cas.js";
|
|||||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||||
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
|
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
|
||||||
|
|
||||||
|
const WORKER_REGISTRY_YAML = `config:
|
||||||
|
maxDepth: 3
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
default: stub/model
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
|
|
||||||
export const descriptor = {
|
export const descriptor = {
|
||||||
@@ -89,6 +100,7 @@ describe("worker process", () => {
|
|||||||
try {
|
try {
|
||||||
const hash = "C9NMV6V2TQT81";
|
const hash = "C9NMV6V2TQT81";
|
||||||
await mkdir(join(root, "bundles"), { recursive: true });
|
await mkdir(join(root, "bundles"), { recursive: true });
|
||||||
|
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
|
||||||
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
||||||
await writeFile(bundlePath, bundleSource, "utf8");
|
await writeFile(bundlePath, bundleSource, "utf8");
|
||||||
|
|
||||||
@@ -136,6 +148,7 @@ describe("worker process", () => {
|
|||||||
try {
|
try {
|
||||||
const hash = "C9NMV6V2TQT81";
|
const hash = "C9NMV6V2TQT81";
|
||||||
await mkdir(join(root, "bundles"), { recursive: true });
|
await mkdir(join(root, "bundles"), { recursive: true });
|
||||||
|
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
|
||||||
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
||||||
await writeFile(bundlePath, bundleSource, "utf8");
|
await writeFile(bundlePath, bundleSource, "utf8");
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,18 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { END } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||||
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
|
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
|
||||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||||
import { executeThread } from "../src/engine/engine.js";
|
import { executeThread } from "../src/engine/engine.js";
|
||||||
import { createExtract } from "../src/extract/extract-fn.js";
|
|
||||||
import {
|
import {
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "../src/registry/registry.js";
|
} from "../src/registry/registry.js";
|
||||||
import { END } from "../src/types.js";
|
|
||||||
import { createLogger } from "../src/util/logger.js";
|
import { createLogger } from "../src/util/logger.js";
|
||||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||||
|
|
||||||
@@ -76,11 +74,16 @@ function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unkno
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentExtract = createExtract({
|
const PARENT_REGISTRY_WITH_CONFIG = `config:
|
||||||
baseUrl: "http://127.0.0.1:9",
|
maxDepth: 3
|
||||||
apiKey: "test",
|
providers:
|
||||||
model: "test",
|
stub:
|
||||||
});
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
default: stub/m
|
||||||
|
workflows: {}
|
||||||
|
`;
|
||||||
|
|
||||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||||
|
|
||||||
@@ -131,6 +134,8 @@ describe("workflowAsAgent integration", () => {
|
|||||||
|
|
||||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
|
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
|
||||||
try {
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
|
||||||
const { hash: childHash } = await installChildWorkflow(root);
|
const { hash: childHash } = await installChildWorkflow(root);
|
||||||
|
|
||||||
const parentWorkflow = createWorkflow<ParentMeta>(
|
const parentWorkflow = createWorkflow<ParentMeta>(
|
||||||
@@ -147,9 +152,7 @@ describe("workflowAsAgent integration", () => {
|
|||||||
},
|
},
|
||||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||||
},
|
},
|
||||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) },
|
{ agent: workflowAsAgent("child-wf", { storageRoot: root }), overrides: null },
|
||||||
parentExtract,
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||||
@@ -173,6 +176,7 @@ describe("workflowAsAgent integration", () => {
|
|||||||
awaitAfterEachYield: async () => {},
|
awaitAfterEachYield: async () => {},
|
||||||
forkSourceThreadId: null,
|
forkSourceThreadId: null,
|
||||||
prefilledDiskSteps: null,
|
prefilledDiskSteps: null,
|
||||||
|
storageRoot: root,
|
||||||
},
|
},
|
||||||
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||||
logger,
|
logger,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||||
import { createCasStore } from "../src/cas/cas.js";
|
import { createCasStore } from "../src/cas/cas.js";
|
||||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||||
import { parseMerkleNode } from "../src/cas/merkle.js";
|
import { parseMerkleNode } from "../src/cas/merkle.js";
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "../src/registry/registry.js";
|
} from "../src/registry/registry.js";
|
||||||
import { type AgentContext, START } from "../src/types.js";
|
|
||||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||||
|
|
||||||
function makeAgentCtx(params: {
|
function makeAgentCtx(params: {
|
||||||
@@ -93,6 +92,21 @@ describe("workflowAsAgent", () => {
|
|||||||
test("runs registered workflow and returns child thread root CAS hash", async () => {
|
test("runs registered workflow and returns child thread root CAS hash", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
|
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
|
||||||
try {
|
try {
|
||||||
|
await mkdir(root, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(root, "workflow.yaml"),
|
||||||
|
`config:
|
||||||
|
maxDepth: 3
|
||||||
|
providers:
|
||||||
|
stub:
|
||||||
|
baseUrl: http://127.0.0.1:9
|
||||||
|
apiKey: test
|
||||||
|
models:
|
||||||
|
default: stub/m
|
||||||
|
workflows: {}
|
||||||
|
`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
await installChildWorkflow(root);
|
await installChildWorkflow(root);
|
||||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||||
const out = await agent(
|
const out = await agent(
|
||||||
@@ -140,12 +154,18 @@ describe("workflowAsAgent", () => {
|
|||||||
...reg.value,
|
...reg.value,
|
||||||
config: {
|
config: {
|
||||||
maxDepth: 2,
|
maxDepth: 2,
|
||||||
extract: {
|
supervisorInterval: 3,
|
||||||
|
providers: {
|
||||||
|
local: {
|
||||||
baseUrl: "http://127.0.0.1:9",
|
baseUrl: "http://127.0.0.1:9",
|
||||||
model: "m",
|
|
||||||
apiKey: "k",
|
apiKey: "k",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
models: {
|
||||||
|
default: "local/m",
|
||||||
|
extract: "local/m",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const wr = await writeWorkflowRegistry(root, withCfg);
|
const wr = await writeWorkflowRegistry(root, withCfg);
|
||||||
expect(wr.ok).toBe(true);
|
expect(wr.ok).toBe(true);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@uncaged/workflow",
|
"name": "@uncaged/workflow",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"types": "src/index.ts",
|
"types": "src/index.ts",
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@uncaged/workflow-runtime": "workspace:*",
|
||||||
"acorn": "^8.16.0",
|
"acorn": "^8.16.0",
|
||||||
"xxhashjs": "^0.2.2",
|
"xxhashjs": "^0.2.2",
|
||||||
"yaml": "^2.8.4"
|
"yaml": "^2.8.4"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||||
import * as z from "zod/v4";
|
import * as z from "zod/v4";
|
||||||
|
|
||||||
import type { RoleMeta, WorkflowDefinition } from "../types.js";
|
|
||||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||||
|
|
||||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function isAllowedImportSpecifier(spec: string): boolean {
|
|||||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (spec === "@uncaged/workflow") {
|
if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return isBuiltin(spec);
|
return isBuiltin(spec);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkflowFn } from "../types.js";
|
import type { WorkflowFn } from "@uncaged/workflow-runtime";
|
||||||
import { err, ok, type Result } from "../util/index.js";
|
import { err, ok, type Result } from "../util/index.js";
|
||||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import type { WorkflowFn } from "../types.js";
|
import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||||
|
|
||||||
/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */
|
export type {
|
||||||
export type WorkflowRoleSchema = Record<string, unknown>;
|
WorkflowDescriptor,
|
||||||
|
WorkflowRoleDescriptor,
|
||||||
export type WorkflowRoleDescriptor = {
|
WorkflowRoleSchema,
|
||||||
description: string;
|
} from "@uncaged/workflow-runtime";
|
||||||
schema: WorkflowRoleSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */
|
|
||||||
export type WorkflowDescriptor = {
|
|
||||||
description: string;
|
|
||||||
roles: Record<string, WorkflowRoleDescriptor>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkflowBundleValidationInput = {
|
export type WorkflowBundleValidationInput = {
|
||||||
/** Absolute or relative path (used for `.esm.js` suffix checks). */
|
/** Absolute or relative path (used for `.esm.js` suffix checks). */
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user