refactor(workflow): fix tsconfig references, template imports, delete old packages/workflow
- Update root tsconfig.json references: replace packages/workflow with 6 new packages - Update cli-workflow tsconfig references to new packages - Add tsconfig references to workflow-util, workflow-runtime, workflow-execute - Fix workflow-agent-llm, workflow-agent-cursor, workflow-agent-hermes, workflow-util-agent tsconfig references (../workflow -> ../workflow-runtime) - Remove stale @uncaged/workflow deps from agent package.json files - Change template packages to import buildDescriptor from @uncaged/workflow-register - Normalize package.json exports field across all new packages - Delete old packages/workflow/ directory
This commit is contained in:
@@ -17,6 +17,13 @@
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow" }],
|
||||
"references": [
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-execute" },
|
||||
{ "path": "../workflow-register" }
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }]
|
||||
"references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [{ "path": "../workflow-runtime" }]
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
"name": "@uncaged/workflow-execute",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" },
|
||||
{ "path": "../workflow-runtime" },
|
||||
{ "path": "../workflow-util" },
|
||||
{ "path": "../workflow-cas" },
|
||||
{ "path": "../workflow-reactor" },
|
||||
{ "path": "../workflow-register" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
"name": "@uncaged/workflow-template-develop",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow";
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { developModerator } from "./moderator.js";
|
||||
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [
|
||||
{ "path": "../workflow-register" },
|
||||
{ "path": "../workflow-runtime" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
"name": "@uncaged/workflow-template-solve-issue",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-register": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildDescriptor } from "@uncaged/workflow";
|
||||
import { buildDescriptor } from "@uncaged/workflow-register";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, solveIssueRoles } from "./roles.js";
|
||||
|
||||
@@ -6,5 +6,8 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [
|
||||
{ "path": "../workflow-register" },
|
||||
{ "path": "../workflow-runtime" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow": "workspace:*",
|
||||
"@uncaged/workflow-runtime": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }]
|
||||
"references": [{ "path": "../workflow-runtime" }]
|
||||
}
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../workflow-protocol" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
# @uncaged/workflow
|
||||
|
||||
Core workflow engine: registry, CAS, thread execution, bundle validation, and role/workflow types.
|
||||
|
||||
This package implements the three-phase engine loop that runs single-file ESM workflow bundles (each exports `run` and `descriptor`). It persists threads under `~/.uncaged/workflow/` by default and hashes bundles with XXH64 (Crockford Base32). See the repo root [README](../../README.md) for workflow, bundle, thread, role, and registry concepts.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @uncaged/workflow zod
|
||||
```
|
||||
|
||||
In this monorepo, depend with `"@uncaged/workflow": "workspace:*"`. `zod` is a peer dependency (used by bundle/shape validation at the integration boundary).
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { createWorkflow, readWorkflowRegistry, executeThread } from "@uncaged/workflow";
|
||||
// Wire a WorkflowDefinition + AgentBinding + extract + optional LlmProvider into createWorkflow,
|
||||
// then run the returned WorkflowFn inside your host (or use executeThread for disk-backed runs).
|
||||
```
|
||||
|
||||
## API overview
|
||||
|
||||
| Area | Exports (representative) |
|
||||
|------|--------------------------|
|
||||
| **Types** | `WorkflowDefinition`, `WorkflowFn`, `AgentFn`, `AgentBinding`, `Moderator`, `RoleDefinition`, `ThreadContext`, `LlmProvider`, `Result` shape via `ok` / `err`, `START` / `END` |
|
||||
| **Bundle** | `buildDescriptor`, `extractBundleExports`, `validateWorkflowBundle`, `validateWorkflowDescriptor`, `WorkflowDescriptor`, `WorkflowRoleDescriptor` |
|
||||
| **Registry** | `readWorkflowRegistry`, `writeWorkflowRegistry`, `registerWorkflowVersion`, `workflowRegistryPath`, YAML helpers |
|
||||
| **CAS** | `createCasStore`, Merkle helpers (`putStepMerkleNode`, `getContentMerklePayload`, …), `hashWorkflowBundleBytes` |
|
||||
| **Engine** | `createWorkflow`, `executeThread`, `parseThreadDataJsonl`, fork helpers, `garbageCollectCas` |
|
||||
| **Extract / LLM tools** | `llmExtract`, `createExtract`, `createThreadReactor`, `createLlmFn`, `getExtractProvider` |
|
||||
| **Agent bridge** | `workflowAsAgent` — expose a registered workflow as an agent-backed role |
|
||||
| **Utilities** | `createLogger`, ULID / Crockford Base32 codecs, `getDefaultWorkflowStorageRoot`, paths |
|
||||
|
||||
Full surface is re-exported from `src/index.ts`.
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
decodeCrockfordBase32Bits,
|
||||
decodeCrockfordToUint64,
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
} from "../src/util/base32.js";
|
||||
|
||||
describe("Crockford Base32", () => {
|
||||
test("roundtrip 64-bit hash encoding", () => {
|
||||
const value = 0xef46_db37_51d8_e999n;
|
||||
const encoded = encodeUint64AsCrockford(value);
|
||||
expect(encoded.length).toBe(13);
|
||||
const decoded = decodeCrockfordToUint64(encoded);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.value).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
test("roundtrip arbitrary bit widths used by ULID (128-bit)", () => {
|
||||
const rand = 0x1234567890abcdef12n & ((1n << 80n) - 1n);
|
||||
const payload = (12345n << 80n) | rand;
|
||||
const encoded = encodeCrockfordBase32Bits(payload, 128);
|
||||
expect(encoded.length).toBe(26);
|
||||
const decoded = decodeCrockfordBase32Bits(encoded, 128);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.value).toBe(payload);
|
||||
}
|
||||
});
|
||||
|
||||
test("reject invalid characters", () => {
|
||||
const decoded = decodeCrockfordToUint64("!!!!!!!!!!!!!");
|
||||
expect(decoded.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
import { buildDescriptor } from "../src/bundle/build-descriptor.js";
|
||||
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
||||
|
||||
describe("buildDescriptor", () => {
|
||||
test("produces a descriptor that validates and includes JSON schemas per role", () => {
|
||||
const schema = z.object({
|
||||
title: z.string(),
|
||||
count: z.number(),
|
||||
});
|
||||
|
||||
type M = { analyst: z.infer<typeof schema> };
|
||||
|
||||
const descriptor = buildDescriptor<M>({
|
||||
description: "Demo workflow",
|
||||
roles: {
|
||||
analyst: {
|
||||
description: "Analyzes input",
|
||||
systemPrompt: "You are an analyst.",
|
||||
extractPrompt: "Extract title and count from the analysis.",
|
||||
schema,
|
||||
extractRefs: null,
|
||||
},
|
||||
},
|
||||
moderator: () => END,
|
||||
});
|
||||
|
||||
const validated = validateWorkflowDescriptor(descriptor);
|
||||
expect(validated.ok).toBe(true);
|
||||
if (!validated.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(validated.value.description).toBe("Demo workflow");
|
||||
const analyst = validated.value.roles.analyst;
|
||||
expect(analyst.description).toBe("Analyzes input");
|
||||
expect(analyst.schema.type).toBe("object");
|
||||
const props = analyst.schema.properties as Record<string, unknown>;
|
||||
expect(props.title).toMatchObject({ type: "string" });
|
||||
expect(props.count).toMatchObject({ type: "number" });
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { validateWorkflowBundle } from "../src/bundle/bundle-validator.js";
|
||||
|
||||
const minimalDescriptor = `export const descriptor = { description: "x", roles: {} };
|
||||
`;
|
||||
|
||||
describe("validateWorkflowBundle", () => {
|
||||
test("accepts export { local as run } when local is a call expression result", () => {
|
||||
const source = `${minimalDescriptor}var wf = createFn({});
|
||||
export { wf as run };
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts minimal valid builtin-only bundle", () => {
|
||||
const source = `${minimalDescriptor}import fs from "node:fs";
|
||||
|
||||
export const run = async function* (input) {
|
||||
fs.existsSync(".");
|
||||
return { returnCode: 0, summary: input.prompt };
|
||||
};
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("allows static import of @uncaged/workflow", () => {
|
||||
const source = `${minimalDescriptor}import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const run = async function* (_input, options) {
|
||||
const cas = options.cas;
|
||||
const h = await putContentMerkleNode(cas, "x");
|
||||
return { returnCode: 0, summary: h };
|
||||
};
|
||||
`;
|
||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||
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", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.js",
|
||||
source: `${minimalDescriptor}export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects default export", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `${minimalDescriptor}export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("default export");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects run export that is not a callable bundle shape", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `${minimalDescriptor}export const run = { x: 1 };
|
||||
`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("run");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects missing run export", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `${minimalDescriptor}export const x = 1;\n`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("run");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects missing descriptor export", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `export const run = async function* (input) {
|
||||
return { returnCode: 0, summary: input.prompt };
|
||||
};
|
||||
`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("descriptor");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects non-builtin imports", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `${minimalDescriptor}import x from "some-package";
|
||||
export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; }
|
||||
`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects dynamic import", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `${minimalDescriptor}export const run = async function* (input) { await import("fs"); return { returnCode: 0, summary: input.prompt }; }
|
||||
`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toContain("dynamic import");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects require()", () => {
|
||||
const r = validateWorkflowBundle({
|
||||
filePath: "/tmp/w.esm.js",
|
||||
source: `${minimalDescriptor}export const run = async function* (input) { require("fs"); return { returnCode: 0, summary: input.prompt }; }
|
||||
`,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { hashString } from "../src/cas/hash.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||
|
||||
function casStoredForm(raw: string): string {
|
||||
return serializeMerkleNode(createContentMerkleNode(raw));
|
||||
}
|
||||
|
||||
describe("createCasStore", () => {
|
||||
let casDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
casDir = await mkdtemp(join(tmpdir(), "cas-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("put returns consistent hash for same content", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const raw = "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(hashString(stored));
|
||||
expect(h1).toHaveLength(13);
|
||||
});
|
||||
|
||||
test("put returns hash matching hashString of merkle-stored form", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const content = "some content to store";
|
||||
const stored = casStoredForm(content);
|
||||
const h = await cas.put(content);
|
||||
expect(h).toBe(hashString(stored));
|
||||
});
|
||||
|
||||
test("get returns merkle-serialized blob for raw puts", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const content = "line1\nline2\nline3";
|
||||
const stored = casStoredForm(content);
|
||||
const h = await cas.put(content);
|
||||
const retrieved = await cas.get(h);
|
||||
expect(retrieved).toBe(stored);
|
||||
});
|
||||
|
||||
test("get returns null for missing hash", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const result = await cas.get("0000000000000");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("delete removes entry", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h = await cas.put("to be deleted");
|
||||
await cas.delete(h);
|
||||
const result = await cas.get(h);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("delete on missing hash does not throw", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
await cas.delete("0000000000000");
|
||||
});
|
||||
|
||||
test("list returns all stored hashes", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("aaa");
|
||||
const h2 = await cas.put("bbb");
|
||||
const h3 = await cas.put("ccc");
|
||||
const hashes = await cas.list();
|
||||
expect(hashes.sort()).toEqual([h1, h2, h3].sort());
|
||||
});
|
||||
|
||||
test("list returns empty array when cas dir does not exist", async () => {
|
||||
const cas = createCasStore(join(casDir, "nonexistent"));
|
||||
const hashes = await cas.list();
|
||||
expect(hashes).toEqual([]);
|
||||
});
|
||||
|
||||
test("put is idempotent — same content written twice causes no error", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const raw = "idempotent";
|
||||
const stored = casStoredForm(raw);
|
||||
const h1 = await cas.put(raw);
|
||||
const h2 = await cas.put(raw);
|
||||
expect(h1).toBe(h2);
|
||||
const content = await cas.get(h1);
|
||||
expect(content).toBe(stored);
|
||||
});
|
||||
|
||||
test("different content produces different hashes", async () => {
|
||||
const cas = createCasStore(casDir);
|
||||
const h1 = await cas.put("alpha");
|
||||
const h2 = await cas.put("beta");
|
||||
expect(h1).not.toBe(h2);
|
||||
});
|
||||
});
|
||||
@@ -1,773 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
parseMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "../src/cas/merkle.js";
|
||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
plan: z.string(),
|
||||
files: z.array(z.string()),
|
||||
});
|
||||
|
||||
const coderMetaSchema = z.object({
|
||||
diff: z.string(),
|
||||
});
|
||||
|
||||
type DemoMeta = {
|
||||
planner: z.infer<typeof plannerMetaSchema>;
|
||||
coder: z.infer<typeof coderMetaSchema>;
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ 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 EXTRACT_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/model
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
async function writeExtractRegistryConfig(storageRoot: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
||||
}
|
||||
|
||||
const SUPERVISOR_INTERVAL_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 2
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
extract: stub/model
|
||||
supervisor: stub/supervisor-cheap
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const SUPERVISOR_LONG_INTERVAL_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
supervisorInterval: 10
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
extract: stub/model
|
||||
supervisor: stub/supervisor-cheap
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
async function writeRegistryYaml(storageRoot: string, yaml: string): Promise<void> {
|
||||
await writeFile(join(storageRoot, "workflow.yaml"), yaml, "utf8");
|
||||
}
|
||||
|
||||
/** Extract and supervisor both run via {@link createThreadReactor}; differentiate by `body.model`. */
|
||||
function installMockExtractThenSupervisor(params: {
|
||||
extractArgs: ReadonlyArray<Record<string, unknown>>;
|
||||
supervisorDecision: "continue" | "stop";
|
||||
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 model = typeof body.model === "string" ? body.model : "";
|
||||
const isSupervisor = model.startsWith("supervisor-");
|
||||
if (!isSupervisor) {
|
||||
const args =
|
||||
params.extractArgs[extractI] ?? params.extractArgs[params.extractArgs.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockExtractThenSupervisor: empty extractArgs");
|
||||
}
|
||||
extractI += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
params.onSupervisorCall?.();
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{ message: { content: JSON.stringify({ decision: params.supervisorDecision }) } },
|
||||
],
|
||||
}),
|
||||
{ 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>(
|
||||
{
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Demo planner",
|
||||
systemPrompt: "You are a planner.",
|
||||
extractPrompt: "Extract plan text and affected files list.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
coder: {
|
||||
description: "Demo coder",
|
||||
systemPrompt: "You are a coder.",
|
||||
extractPrompt: "Extract the code diff summary.",
|
||||
schema: coderMetaSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => {
|
||||
if (ctx.steps.length === 0) {
|
||||
return "planner";
|
||||
}
|
||||
if (ctx.steps.length === 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
},
|
||||
},
|
||||
{
|
||||
agent: async () => "unused",
|
||||
overrides: {
|
||||
planner: async () => "plan-body",
|
||||
coder: async () => "code-body",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
describe("executeThread", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("writes RFC-001 `.data.jsonl` start + role records and `.info.jsonl` logs", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{ plan: "do-it", files: ["a.ts"] },
|
||||
{ diff: "+ok" },
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-"));
|
||||
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 writeExtractRegistryConfig(root);
|
||||
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: "Fix the login redirect bug in #3", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
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(typeof result.rootHash).toBe("string");
|
||||
expect(result.rootHash.length).toBeGreaterThan(0);
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
expect(rootYaml).not.toBeNull();
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.type).toBe("thread");
|
||||
const rootPayload = rootNode.payload as Record<string, unknown>;
|
||||
expect(rootPayload.workflow).toBe("demo-flow");
|
||||
expect(rootPayload.threadId).toBe(threadId);
|
||||
const rootResult = rootPayload.result as Record<string, unknown>;
|
||||
expect(rootResult.returnCode).toBe(0);
|
||||
expect(rootNode.children.length).toBe(2);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
|
||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(start.name).toBe("demo-flow");
|
||||
expect(start.hash).toBe(hash);
|
||||
expect(start.threadId).toBe(threadId);
|
||||
expect(typeof start.timestamp).toBe("number");
|
||||
|
||||
const params = start.parameters as Record<string, unknown>;
|
||||
expect(params.prompt).toBe("Fix the login redirect bug in #3");
|
||||
const opts = params.options as Record<string, unknown>;
|
||||
expect(opts.maxRounds).toBe(5);
|
||||
expect(opts.depth).toBe(0);
|
||||
expect(Object.keys(opts).sort()).toEqual(["depth", "maxRounds"]);
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("planner");
|
||||
expect(typeof role1.contentHash).toBe("string");
|
||||
expect(await getContentMerklePayload(cas, String(role1.contentHash))).toBe("plan-body");
|
||||
expect(role1.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||
expect(role1.refs).toEqual([role1.contentHash]);
|
||||
expect(typeof role1.timestamp).toBe("number");
|
||||
|
||||
const role2 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(role2.role).toBe("coder");
|
||||
expect(role2.refs).toEqual([role2.contentHash]);
|
||||
|
||||
const step1Yaml = await cas.get(rootNode.children[0] ?? "");
|
||||
const step2Yaml = await cas.get(rootNode.children[1] ?? "");
|
||||
expect(step1Yaml).not.toBeNull();
|
||||
expect(step2Yaml).not.toBeNull();
|
||||
const step1Node = parseMerkleNode(step1Yaml ?? "");
|
||||
const step2Node = parseMerkleNode(step2Yaml ?? "");
|
||||
expect(step1Node.type).toBe("step");
|
||||
expect(step2Node.type).toBe("step");
|
||||
expect(step1Node.children).toEqual([String(role1.contentHash)]);
|
||||
expect(step2Node.children).toEqual([String(role2.contentHash)]);
|
||||
const step1Payload = step1Node.payload as Record<string, unknown>;
|
||||
expect(step1Payload.role).toBe("planner");
|
||||
expect(step1Payload.meta).toEqual({ plan: "do-it", files: ["a.ts"] });
|
||||
|
||||
const infoText = await readFile(infoPath, "utf8");
|
||||
const infoLines = infoText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(infoLines.length).toBeGreaterThan(0);
|
||||
const log0 = JSON.parse(infoLines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(typeof log0.tag).toBe("string");
|
||||
expect(String(log0.tag).length).toBe(8);
|
||||
expect(typeof log0.content).toBe("string");
|
||||
expect(typeof log0.timestamp).toBe("number");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pre-filled input.steps skips roles already present", async () => {
|
||||
restoreFetch = installMockChatCompletions([{ diff: "+ok" }]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-fork-"));
|
||||
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 writeExtractRegistryConfig(root);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerHash = await cas.put(serializeMerkleNode(createContentMerkleNode("plan-body")));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const histTs = 9_000_000;
|
||||
const mergedPlannerRefs = ["CAS111AAAAAAA", plannerHash];
|
||||
const result = await executeThread(
|
||||
demoWorkflow,
|
||||
"demo-flow",
|
||||
{
|
||||
prompt: "continue from planner",
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerHash,
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
refs: mergedPlannerRefs,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: "01SRC1111111111111111111",
|
||||
prefilledDiskSteps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerHash,
|
||||
meta: { plan: "do-it", files: ["a.ts"] },
|
||||
refs: mergedPlannerRefs,
|
||||
timestamp: histTs,
|
||||
},
|
||||
],
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.children.length).toBe(2);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
|
||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(start.forkFrom).toEqual({ threadId: "01SRC1111111111111111111" });
|
||||
|
||||
const role0 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role0.role).toBe("planner");
|
||||
expect(role0.timestamp).toBe(histTs);
|
||||
expect(role0.refs).toEqual(mergedPlannerRefs);
|
||||
|
||||
const role1 = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("coder");
|
||||
expect(await getContentMerklePayload(cas, String(role1.contentHash))).toBe("code-body");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("respects maxRounds=0 (start record only)", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-max0-"));
|
||||
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 });
|
||||
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: "hello", steps: [] },
|
||||
{
|
||||
maxRounds: 0,
|
||||
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(typeof result.rootHash).toBe("string");
|
||||
|
||||
const rootYaml = await cas.get(result.rootHash);
|
||||
const rootNode = parseMerkleNode(rootYaml ?? "");
|
||||
expect(rootNode.type).toBe("thread");
|
||||
expect(rootNode.children.length).toBe(0);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Merkle DAG: root → step nodes → content for full thread traversal", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{ plan: "do-it", files: ["a.ts"] },
|
||||
{ diff: "+ok" },
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-dag-"));
|
||||
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 writeExtractRegistryConfig(root);
|
||||
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: "DAG test", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(3);
|
||||
|
||||
const rolePlanner = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
const roleCoder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
|
||||
const threadYaml = await cas.get(result.rootHash);
|
||||
expect(threadYaml).not.toBeNull();
|
||||
const threadNode = parseMerkleNode(threadYaml ?? "");
|
||||
expect(threadNode.type).toBe("thread");
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const stepHash of threadNode.children) {
|
||||
const stepYaml = await cas.get(stepHash);
|
||||
expect(stepYaml).not.toBeNull();
|
||||
const stepNode = parseMerkleNode(stepYaml ?? "");
|
||||
expect(stepNode.type).toBe("step");
|
||||
expect(stepNode.children.length).toBe(1);
|
||||
const contentHash = stepNode.children[0];
|
||||
expect(contentHash).toBeDefined();
|
||||
const body = await getContentMerklePayload(cas, contentHash ?? "");
|
||||
expect(body).not.toBeNull();
|
||||
bodies.push(body ?? "");
|
||||
}
|
||||
|
||||
expect(bodies.sort()).toEqual(["code-body", "plan-body"].sort());
|
||||
expect(rolePlanner.role).toBe("planner");
|
||||
expect(roleCoder.role).toBe("coder");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("extract traverses CAS DAG via cas_get during extraction", async () => {
|
||||
const dagMetaSchema = z.object({ leafPayload: z.string() });
|
||||
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
let fetchRound = 0;
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-engine-react-"));
|
||||
try {
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const leafYaml = serializeMerkleNode(createContentMerkleNode("needle-from-leaf"));
|
||||
const leafHash = await cas.put(leafYaml);
|
||||
const rootYaml = serializeMerkleNode({
|
||||
type: "thread",
|
||||
payload: {
|
||||
workflow: "dag-demo",
|
||||
threadId: "01DAG00000000000000000001",
|
||||
result: { returnCode: 0, summary: "" },
|
||||
},
|
||||
children: [leafHash],
|
||||
});
|
||||
const dagRootHash = await cas.put(rootYaml);
|
||||
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
fetchRound += 1;
|
||||
if (fetchRound === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: dagRootHash }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (fetchRound === 2) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: leafHash }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c3",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ leafPayload: "needle-from-leaf" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const dagWorkflow = createWorkflow<DagDemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
walker: {
|
||||
description: "DAG walker",
|
||||
systemPrompt: "Output only the root CAS hash.",
|
||||
extractPrompt:
|
||||
"Set leafPayload to the string payload of the content Merkle node under the root.",
|
||||
schema: dagMetaSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
|
||||
},
|
||||
{ agent: async () => dagRootHash, overrides: null },
|
||||
);
|
||||
|
||||
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 writeExtractRegistryConfig(root);
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
dagWorkflow,
|
||||
"dag-demo",
|
||||
{ prompt: "traverse", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
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(fetchRound).toBe(3);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
const roleRec = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(roleRec.role).toBe("walker");
|
||||
expect(roleRec.meta).toEqual({ leafPayload: "needle-from-leaf" });
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
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" }],
|
||||
supervisorDecision: "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" }],
|
||||
supervisorDecision: "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,127 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
buildForkPlan,
|
||||
parseThreadDataJsonl,
|
||||
selectForkHistoricalSteps,
|
||||
} from "../src/engine/fork-thread.js";
|
||||
|
||||
const sampleDataJsonl = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","contentHash":"HP0000000000000000000001","meta":{},"refs":[],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HP0000000000000000000002","meta":{},"refs":[],"timestamp":102}
|
||||
{"role":"reviewer","contentHash":"HP0000000000000000000003","meta":{},"refs":[],"timestamp":103}
|
||||
`;
|
||||
|
||||
describe("fork-thread", () => {
|
||||
test("parseThreadDataJsonl reads start + role steps", () => {
|
||||
const r = parseThreadDataJsonl(sampleDataJsonl);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.start.workflowName).toBe("demo");
|
||||
expect(r.value.start.hash).toBe("C9NMV6V2TQT81");
|
||||
expect(r.value.start.threadId).toBe("01AAA1111111111111111111");
|
||||
expect(r.value.start.prompt).toBe("hi");
|
||||
expect(r.value.start.maxRounds).toBe(5);
|
||||
expect(r.value.start.depth).toBe(0);
|
||||
expect(r.value.roleSteps.length).toBe(3);
|
||||
expect(r.value.roleSteps[0]?.role).toBe("planner");
|
||||
});
|
||||
|
||||
test("selectForkHistoricalSteps: --from-role keeps through first matching role", () => {
|
||||
const parsed = parseThreadDataJsonl(sampleDataJsonl);
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) {
|
||||
return;
|
||||
}
|
||||
const sel = selectForkHistoricalSteps(parsed.value.roleSteps, "planner");
|
||||
expect(sel.ok).toBe(true);
|
||||
if (!sel.ok) {
|
||||
return;
|
||||
}
|
||||
expect(sel.value.length).toBe(1);
|
||||
expect(sel.value[0]?.role).toBe("planner");
|
||||
});
|
||||
|
||||
test("selectForkHistoricalSteps: retry last drops final step", () => {
|
||||
const parsed = parseThreadDataJsonl(sampleDataJsonl);
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) {
|
||||
return;
|
||||
}
|
||||
const sel = selectForkHistoricalSteps(parsed.value.roleSteps, null);
|
||||
expect(sel.ok).toBe(true);
|
||||
if (!sel.ok) {
|
||||
return;
|
||||
}
|
||||
expect(sel.value.map((s) => s.role)).toEqual(["planner", "coder"]);
|
||||
});
|
||||
|
||||
test("selectForkHistoricalSteps: unknown role lists available names", () => {
|
||||
const parsed = parseThreadDataJsonl(sampleDataJsonl);
|
||||
expect(parsed.ok).toBe(true);
|
||||
if (!parsed.ok) {
|
||||
return;
|
||||
}
|
||||
const sel = selectForkHistoricalSteps(parsed.value.roleSteps, "nope");
|
||||
expect(sel.ok).toBe(false);
|
||||
if (sel.ok) {
|
||||
return;
|
||||
}
|
||||
expect(sel.error).toContain("planner");
|
||||
expect(sel.error).toContain("coder");
|
||||
expect(sel.error).toContain("reviewer");
|
||||
});
|
||||
|
||||
test("buildForkPlan composes worker payload", () => {
|
||||
const r = buildForkPlan(sampleDataJsonl, "planner");
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.sourceThreadId).toBe("01AAA1111111111111111111");
|
||||
expect(r.value.workflowName).toBe("demo");
|
||||
expect(r.value.historicalSteps.length).toBe(1);
|
||||
expect(r.value.historicalSteps[0]?.timestamp).toBe(101);
|
||||
expect(r.value.runOptions).toEqual({ maxRounds: 5, depth: 0 });
|
||||
});
|
||||
|
||||
test("parseThreadDataJsonl ignores trailing WorkflowResult line", () => {
|
||||
const text = `${sampleDataJsonl.trim()}\n{"returnCode":0,"summary":"done"}\n`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.roleSteps.length).toBe(3);
|
||||
expect(r.value.roleSteps[2]?.role).toBe("reviewer");
|
||||
});
|
||||
|
||||
test("parseThreadDataJsonl errors when WorkflowResult is not last", () => {
|
||||
const text = `{"name":"demo","hash":"H","threadId":"01ZZZZZZZZZZZZZZZZZZZZZZ","parameters":{"prompt":"p","options":{"maxRounds":3}},"timestamp":1}
|
||||
{"returnCode":0,"summary":"early"}
|
||||
{"role":"planner","content":"x","meta":{},"timestamp":2}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("parseThreadDataJsonl reads explicit depth from start record", () => {
|
||||
const text = `{"name":"demo","hash":"H","threadId":"01ZZZZZZZZZZZZZZZZZZZZZZ","parameters":{"prompt":"p","options":{"maxRounds":3,"depth":2}},"timestamp":1}
|
||||
{"role":"planner","contentHash":"HP0000000000000000000099","meta":{},"refs":[],"timestamp":2}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.start.depth).toBe(2);
|
||||
const plan = buildForkPlan(text, null);
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
return;
|
||||
}
|
||||
expect(plan.value.runOptions).toEqual({ maxRounds: 3, depth: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||
import { decodeCrockfordToUint64 } from "../src/util/base32.js";
|
||||
|
||||
describe("hashWorkflowBundleBytes", () => {
|
||||
test("matches XXH64 reference for empty input", () => {
|
||||
const encoder = new TextEncoder();
|
||||
const digest = hashWorkflowBundleBytes(encoder.encode(""));
|
||||
const decoded = decodeCrockfordToUint64(digest);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
expect(decoded.value).toBe(0xef46_db37_51d8_e999n);
|
||||
}
|
||||
});
|
||||
|
||||
test("stable for identical content", () => {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(
|
||||
`export const descriptor = { description: "x", roles: {} };
|
||||
export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; }
|
||||
`,
|
||||
);
|
||||
expect(hashWorkflowBundleBytes(data)).toBe(hashWorkflowBundleBytes(data));
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
|
||||
describe("createLogger", () => {
|
||||
test("writes JSONL records to a file sink", async () => {
|
||||
const dir = join(tmpdir(), `wf-log-${process.pid}-${Date.now()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const logPath = join(dir, "test.log");
|
||||
const log = createLogger({ sink: { kind: "file", path: logPath } });
|
||||
log("01ABCDEF", "hello");
|
||||
const text = await readFile(logPath, "utf8");
|
||||
const line = text.trim().split("\n")[0];
|
||||
expect(line).toBeDefined();
|
||||
const obj = JSON.parse(line ?? "{}") as { tag: string; content: string; timestamp: number };
|
||||
expect(obj.tag).toBe("01ABCDEF");
|
||||
expect(obj.content).toBe("hello");
|
||||
expect(typeof obj.timestamp).toBe("number");
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("rejects invalid tags", () => {
|
||||
const log = createLogger({ sink: { kind: "stderr" } });
|
||||
expect(() => log("BAD", "x")).toThrow();
|
||||
expect(() => log("01abcdefg", "x")).toThrow();
|
||||
expect(() => log("01ABCDEO", "x")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
createContentMerkleNode,
|
||||
parseMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "../src/cas/merkle.js";
|
||||
|
||||
describe("merkle", () => {
|
||||
test("content node roundtrips through YAML", () => {
|
||||
const node = createContentMerkleNode("hello\nworld");
|
||||
const yaml = serializeMerkleNode(node);
|
||||
const back = parseMerkleNode(yaml);
|
||||
expect(back).toEqual(node);
|
||||
});
|
||||
|
||||
test("step node with object payload roundtrips", () => {
|
||||
const node = {
|
||||
type: "step" as const,
|
||||
payload: { role: "planner", foo: 1 },
|
||||
children: ["ABC123", "DEF456"],
|
||||
};
|
||||
const yaml = serializeMerkleNode(node);
|
||||
const back = parseMerkleNode(yaml);
|
||||
expect(back.type).toBe("step");
|
||||
expect(back.payload).toEqual({ role: "planner", foo: 1 });
|
||||
expect(back.children).toEqual(["ABC123", "DEF456"]);
|
||||
});
|
||||
|
||||
test("parse rejects invalid YAML root", () => {
|
||||
expect(() => parseMerkleNode("[]")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import { buildForkPlan, parseThreadDataJsonl } from "../src/engine/fork-thread.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
|
||||
const phaseSchema = z.object({
|
||||
hash: z.string(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
const plannerMetaSchema = z.object({
|
||||
phases: z.array(phaseSchema),
|
||||
});
|
||||
|
||||
type RefsDemoMeta = {
|
||||
planner: z.infer<typeof plannerMetaSchema>;
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ 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 EXTRACT_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/model
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
|
||||
{
|
||||
roles: {
|
||||
planner: {
|
||||
description: "Planner with phase hashes",
|
||||
systemPrompt: "Plan.",
|
||||
extractPrompt: "Extract phases with CAS hashes.",
|
||||
schema: plannerMetaSchema,
|
||||
extractRefs: (meta) => meta.phases.map((p) => p.hash),
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
|
||||
},
|
||||
{
|
||||
agent: async () => "plan-output",
|
||||
overrides: null,
|
||||
},
|
||||
);
|
||||
|
||||
describe("RoleStep refs tracking", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("parseThreadDataJsonl reads refs and defaults missing refs to []", () => {
|
||||
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","contentHash":"HPAYLOAD111111","meta":{},"refs":["H111AAAAAAAAA","H222AAAAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HPAYLOAD222222","meta":{},"timestamp":102}
|
||||
`;
|
||||
const r = parseThreadDataJsonl(text);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.roleSteps[0]?.refs).toEqual(["H111AAAAAAAAA", "H222AAAAAAAAA"]);
|
||||
expect(r.value.roleSteps[1]?.refs).toEqual([]);
|
||||
});
|
||||
|
||||
test("executeThread persists refs from extractRefs on role yields", async () => {
|
||||
restoreFetch = installMockChatCompletions([
|
||||
{
|
||||
phases: [
|
||||
{ hash: "C9NMV6V2TQT81", title: "phase-a" },
|
||||
{ hash: "C9NMV6V2TQT82", title: "phase-b" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-refs-"));
|
||||
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 writeFile(join(root, "workflow.yaml"), EXTRACT_REGISTRY_YAML, "utf8");
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
refsDemoWorkflow,
|
||||
"refs-demo",
|
||||
{ prompt: "task", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
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(typeof result.rootHash).toBe("string");
|
||||
expect(result.rootHash.length).toBeGreaterThan(0);
|
||||
|
||||
const dataText = await readFile(dataPath, "utf8");
|
||||
const lines = dataText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(2);
|
||||
|
||||
const role1 = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(role1.role).toBe("planner");
|
||||
const refs = role1.refs as string[];
|
||||
expect(refs).toContain("C9NMV6V2TQT81");
|
||||
expect(refs).toContain("C9NMV6V2TQT82");
|
||||
expect(typeof role1.contentHash).toBe("string");
|
||||
expect(refs).toContain(String(role1.contentHash));
|
||||
expect(refs.length).toBe(3);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("buildForkPlan carries refs on historical steps", () => {
|
||||
const text = `{"name":"demo","hash":"C9NMV6V2TQT81","threadId":"01AAA1111111111111111111","parameters":{"prompt":"hi","options":{"maxRounds":5}},"timestamp":100}
|
||||
{"role":"planner","contentHash":"HP111111111111","meta":{},"refs":["KEEPREFAAAAAA"],"timestamp":101}
|
||||
{"role":"coder","contentHash":"HP222222222222","meta":{},"refs":["CODERHASHAAAA"],"timestamp":102}
|
||||
`;
|
||||
const plan = buildForkPlan(text, null);
|
||||
expect(plan.ok).toBe(true);
|
||||
if (!plan.ok) {
|
||||
return;
|
||||
}
|
||||
expect(plan.value.historicalSteps.length).toBe(1);
|
||||
expect(plan.value.historicalSteps[0]?.refs).toEqual(["KEEPREFAAAAAA"]);
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
unregisterWorkflow,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry/registry.js";
|
||||
|
||||
describe("workflow registry", () => {
|
||||
test("roundtrips through workflow.yaml", async () => {
|
||||
const dir = join(tmpdir(), `wf-reg-${process.pid}-${Date.now()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const empty = await readWorkflowRegistry(dir);
|
||||
expect(empty.ok).toBe(true);
|
||||
if (!empty.ok) {
|
||||
return;
|
||||
}
|
||||
expect(empty.value.config).toBeNull();
|
||||
|
||||
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
|
||||
const w1 = await writeWorkflowRegistry(dir, r1);
|
||||
expect(w1.ok).toBe(true);
|
||||
|
||||
const back = await readWorkflowRegistry(dir);
|
||||
expect(back.ok).toBe(true);
|
||||
if (!back.ok) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
expect(back.value.workflows["solve-issue"]?.hash).toBe("AAAAAAAAAAAAA");
|
||||
|
||||
const r2 = registerWorkflowVersion(back.value, "solve-issue", "BBBBBBBBBBBBB", 200);
|
||||
expect(r2.workflows["solve-issue"]?.history[0]?.hash).toBe("AAAAAAAAAAAAA");
|
||||
|
||||
const removed = unregisterWorkflow(r2, "solve-issue");
|
||||
expect(removed.ok).toBe(true);
|
||||
if (!removed.ok) {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const w2 = await writeWorkflowRegistry(dir, removed.value);
|
||||
expect(w2.ok).toBe(true);
|
||||
|
||||
const finalRead = await readWorkflowRegistry(dir);
|
||||
expect(finalRead.ok).toBe(true);
|
||||
if (finalRead.ok) {
|
||||
expect(finalRead.value.workflows["solve-issue"]).toBeUndefined();
|
||||
}
|
||||
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("treats missing registry as empty", async () => {
|
||||
const dir = join(tmpdir(), `wf-reg2-${process.pid}-${Date.now()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const empty = await readWorkflowRegistry(dir);
|
||||
expect(empty.ok).toBe(true);
|
||||
if (empty.ok) {
|
||||
expect(Object.keys(empty.value.workflows).length).toBe(0);
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => {
|
||||
let reg = registerWorkflowVersion({ config: null, workflows: {} }, "solve-issue", "H1", 100);
|
||||
reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200);
|
||||
reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300);
|
||||
const entry = reg.workflows["solve-issue"];
|
||||
expect(entry).toBeDefined();
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
expect(entry.hash).toBe("H3");
|
||||
expect(entry.history.map((h) => h.hash)).toEqual(["H2", "H1"]);
|
||||
|
||||
const toH2 = rollbackWorkflowToHistoryHash(entry, null);
|
||||
expect(toH2.ok).toBe(true);
|
||||
if (!toH2.ok) {
|
||||
return;
|
||||
}
|
||||
expect(toH2.value.hash).toBe("H2");
|
||||
expect(toH2.value.history.map((h) => h.hash)).toEqual(["H3", "H1"]);
|
||||
|
||||
const toH1 = rollbackWorkflowToHistoryHash(toH2.value, "H1");
|
||||
expect(toH1.ok).toBe(true);
|
||||
if (!toH1.ok) {
|
||||
return;
|
||||
}
|
||||
expect(toH1.value.hash).toBe("H1");
|
||||
expect(toH1.value.history.map((h) => h.hash)).toEqual(["H2", "H3"]);
|
||||
|
||||
const bad = rollbackWorkflowToHistoryHash(toH1.value, "NONE");
|
||||
expect(bad.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("parses config section and literal apiKey", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://example.com/v1
|
||||
apiKey: secret-key
|
||||
models:
|
||||
default: dashscope/qwen-turbo
|
||||
extract: dashscope/qwen-plus
|
||||
workflows:
|
||||
solve-issue:
|
||||
hash: SPVR4BDMSGC1W
|
||||
timestamp: 1
|
||||
history: []
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config).not.toBeNull();
|
||||
if (r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.maxDepth).toBe(3);
|
||||
expect(r.value.config.providers.dashscope?.baseUrl).toBe("https://example.com/v1");
|
||||
expect(r.value.config.providers.dashscope?.apiKey).toBe("secret-key");
|
||||
expect(r.value.config.models.extract).toBe("dashscope/qwen-plus");
|
||||
expect(r.value.config.models.default).toBe("dashscope/qwen-turbo");
|
||||
expect(r.value.config.supervisorInterval).toBe(3);
|
||||
});
|
||||
|
||||
test("defaults supervisorInterval to 3 when omitted", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 0
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: k
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok || r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.supervisorInterval).toBe(3);
|
||||
});
|
||||
|
||||
test("parses explicit supervisorInterval", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 0
|
||||
supervisorInterval: 7
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: k
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok || r.value.config === null) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config.supervisorInterval).toBe(7);
|
||||
});
|
||||
|
||||
test("parse errors when supervisorInterval is negative", () => {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 0
|
||||
supervisorInterval: -1
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: k
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("parses config apiKey env: prefix from process.env", () => {
|
||||
const prev = process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
process.env.WF_REGISTRY_TEST_API_KEY = "from-env";
|
||||
try {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
providers:
|
||||
dashscope:
|
||||
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY
|
||||
models:
|
||||
default: dashscope/qwen-plus
|
||||
extract: dashscope/qwen-plus
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.config?.providers.dashscope?.apiKey).toBe("from-env");
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY;
|
||||
} else {
|
||||
process.env.WF_REGISTRY_TEST_API_KEY = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors when env: apiKey variable is unset", () => {
|
||||
const prev = process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||
delete process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
|
||||
try {
|
||||
const yaml = `
|
||||
config:
|
||||
maxDepth: 1
|
||||
providers:
|
||||
p:
|
||||
baseUrl: https://example.com
|
||||
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
|
||||
models:
|
||||
default: p/m
|
||||
workflows: {}
|
||||
`;
|
||||
const r = parseWorkflowRegistryYaml(yaml);
|
||||
expect(r.ok).toBe(false);
|
||||
} finally {
|
||||
if (prev !== undefined) {
|
||||
process.env.WF_REGISTRY_TEST_API_KEY_UNSET = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors on invalid shape", async () => {
|
||||
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, "workflow.yaml"), 'workflows: "broken"\n', "utf8");
|
||||
const bad = await readWorkflowRegistry(dir);
|
||||
expect(bad.ok).toBe(false);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { resolveModel } from "../src/config/resolve-model.js";
|
||||
import type { WorkflowConfig } from "../src/registry/index.js";
|
||||
|
||||
function sampleConfig(): WorkflowConfig {
|
||||
return {
|
||||
maxDepth: 3,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
dashscope: {
|
||||
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
apiKey: "secret",
|
||||
},
|
||||
other: {
|
||||
baseUrl: "https://other.example/v1",
|
||||
apiKey: "k2",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: "dashscope/qwen-plus",
|
||||
extract: "other/foo/bar-model",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveModel", () => {
|
||||
test("uses explicit scene mapping", () => {
|
||||
const config = sampleConfig();
|
||||
const r = resolveModel(config, "extract");
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.baseUrl).toBe("https://other.example/v1");
|
||||
expect(r.value.apiKey).toBe("k2");
|
||||
expect(r.value.model).toBe("foo/bar-model");
|
||||
});
|
||||
|
||||
test("falls back to models.default when scene is missing", () => {
|
||||
const config = sampleConfig();
|
||||
const r = resolveModel(config, "unknown-scene");
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value.model).toBe("qwen-plus");
|
||||
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
|
||||
});
|
||||
|
||||
test("errs when scene missing and no default", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
extract: "p/m",
|
||||
},
|
||||
};
|
||||
const r = resolveModel(config, "other");
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.error).toContain("no model mapping");
|
||||
expect(r.error).toContain("default");
|
||||
});
|
||||
|
||||
test("errs when provider is unknown", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
default: "missing/m",
|
||||
},
|
||||
};
|
||||
const r = resolveModel(config, "any");
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.error).toContain("unknown provider");
|
||||
});
|
||||
|
||||
test("errs on invalid model reference shape", () => {
|
||||
const config: WorkflowConfig = {
|
||||
maxDepth: 1,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
p: { baseUrl: "https://x", apiKey: "k" },
|
||||
},
|
||||
models: {
|
||||
default: "no-slash-model",
|
||||
},
|
||||
};
|
||||
const r = resolveModel(config, "x");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { err, ok } from "../src/util/result.js";
|
||||
|
||||
describe("result helpers", () => {
|
||||
test("ok wraps value", () => {
|
||||
const r = ok(42);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.value).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
test("err wraps error", () => {
|
||||
const r = err("nope");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe("nope");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "../src/util/storage-root.js";
|
||||
|
||||
describe("getGlobalCasDir", () => {
|
||||
test("joins cas segment under explicit storage root", () => {
|
||||
expect(getGlobalCasDir("/tmp/wf-root")).toBe(join("/tmp/wf-root", "cas"));
|
||||
});
|
||||
|
||||
test("defaults to default workflow root when storage root is undefined", () => {
|
||||
expect(getGlobalCasDir(undefined)).toBe(join(getDefaultWorkflowStorageRoot(), "cas"));
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
|
||||
import { 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function installFetchMock(impl: (init?: RequestInit) => Promise<Response>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => impl(init),
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
return () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
}
|
||||
|
||||
describe("runSupervisor", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("returns continue when supervisor model cannot be resolved (no fetch)", async () => {
|
||||
restoreFetch = installFetchMock(async () => {
|
||||
throw new Error("fetch should not run when supervisor is not configured");
|
||||
});
|
||||
|
||||
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 structured tool call", async () => {
|
||||
restoreFetch = installFetchMock(async () =>
|
||||
jsonResponse({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "supervisor_decision",
|
||||
arguments: JSON.stringify({ decision: "stop" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
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 continue from plain JSON content (reactor short-circuit)", async () => {
|
||||
restoreFetch = installFetchMock(async () =>
|
||||
jsonResponse({
|
||||
choices: [{ message: { content: '{"decision":"continue"}' } }],
|
||||
}),
|
||||
);
|
||||
|
||||
const r = await runSupervisor({
|
||||
config: supervisorOnlyConfig(),
|
||||
prompt: "do Y",
|
||||
recentSteps: [],
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) {
|
||||
return;
|
||||
}
|
||||
expect(r.value).toBe("continue");
|
||||
});
|
||||
|
||||
test("returns err when reactor cannot validate the schema within max rounds", async () => {
|
||||
restoreFetch = installFetchMock(async () =>
|
||||
jsonResponse({
|
||||
choices: [{ message: { content: "not-json" } }],
|
||||
}),
|
||||
);
|
||||
|
||||
const r = await runSupervisor({
|
||||
config: supervisorOnlyConfig(),
|
||||
prompt: "p",
|
||||
recentSteps: [],
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("returns err on HTTP failure", async () => {
|
||||
restoreFetch = installFetchMock(async () => new Response("boom", { status: 500 }));
|
||||
|
||||
const r = await runSupervisor({
|
||||
config: supervisorOnlyConfig(),
|
||||
prompt: "p",
|
||||
recentSteps: [],
|
||||
logger: noopLogger,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("RFC-001 thread JSONL shapes", () => {
|
||||
test("documents the `.data.jsonl` start record + role record keys", () => {
|
||||
const startRecord = {
|
||||
name: "solve-issue",
|
||||
hash: "C9NMV6V2TQT81",
|
||||
threadId: "01KQXKW18CT8G75T53R8F4G7YG",
|
||||
parameters: {
|
||||
prompt: "Fix the login redirect bug in #3",
|
||||
options: {
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
},
|
||||
},
|
||||
timestamp: 1714963200000,
|
||||
};
|
||||
|
||||
const roleRecord = {
|
||||
role: "planner",
|
||||
contentHash: "CPHASH000000000000000001",
|
||||
meta: { plan: "...", files: ["src/auth.ts"] },
|
||||
refs: [] as string[],
|
||||
timestamp: 1714963201000,
|
||||
};
|
||||
|
||||
expect(Object.keys(startRecord).sort()).toEqual(
|
||||
["hash", "name", "parameters", "threadId", "timestamp"].sort(),
|
||||
);
|
||||
expect(Object.keys(roleRecord).sort()).toEqual(
|
||||
["contentHash", "meta", "refs", "role", "timestamp"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("documents the `.info.jsonl` debug record keys", () => {
|
||||
const infoRecord = {
|
||||
tag: "4KNMR2PX",
|
||||
content: "Loading workflow bundle...",
|
||||
timestamp: 1714963200500,
|
||||
};
|
||||
|
||||
expect(Object.keys(infoRecord).sort()).toEqual(["content", "tag", "timestamp"].sort());
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { createThreadPauseGate } from "../src/engine/thread-pause-gate.js";
|
||||
|
||||
describe("createThreadPauseGate", () => {
|
||||
test("pause blocks awaitAfterYield until resume", async () => {
|
||||
const gate = createThreadPauseGate();
|
||||
gate.pause();
|
||||
|
||||
let progressed = false;
|
||||
const wait = (async () => {
|
||||
await gate.awaitAfterYield();
|
||||
progressed = true;
|
||||
})();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(progressed).toBe(false);
|
||||
|
||||
gate.resume();
|
||||
await wait;
|
||||
expect(progressed).toBe(true);
|
||||
});
|
||||
|
||||
test("duplicate pause and resume are rejected", () => {
|
||||
const gate = createThreadPauseGate();
|
||||
expect(gate.pause().ok).toBe(true);
|
||||
expect(gate.pause().ok).toBe(false);
|
||||
expect(gate.resume().ok).toBe(true);
|
||||
expect(gate.resume().ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||
import { extractFunctionToolFromZodSchema } from "../src/extract/llm-extract.js";
|
||||
import { createLlmFn, createThreadReactor } from "../src/reactor/index.js";
|
||||
|
||||
const metaSchema = z.object({ seen: z.string() });
|
||||
|
||||
const provider: LlmProvider = {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "test",
|
||||
model: "test",
|
||||
};
|
||||
|
||||
const CAS_GET_TOOL_DEFINITION = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "cas_get",
|
||||
description: "Read CAS node",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", description: "hash" },
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type ThreadCtx = { cas: ReturnType<typeof createCasStore> };
|
||||
|
||||
function createTestReactor() {
|
||||
const llm = createLlmFn(provider);
|
||||
return createThreadReactor<ThreadCtx>({
|
||||
llm,
|
||||
maxRounds: 10,
|
||||
staticTools: [CAS_GET_TOOL_DEFINITION],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`Extract metadata. Use cas_get when needed. Call ${structuredToolName} with JSON args matching the schema, or reply with plain JSON.`,
|
||||
toolHandler: async (call, thread) => {
|
||||
if (call.function.name !== "cas_get") {
|
||||
return `unexpected tool ${call.function.name}`;
|
||||
}
|
||||
const ta = JSON.parse(call.function.arguments) as { hash: string };
|
||||
const blob = await thread.cas.get(ta.hash);
|
||||
return blob === null ? "null" : blob;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("createThreadReactor (extract-shaped)", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("cas_get rounds then extract tool yields validated meta", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "thread-reactor-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const blob = serializeMerkleNode(createContentMerkleNode("needle"));
|
||||
const h = await cas.put(blob);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
let round = 0;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
round += 1;
|
||||
if (round === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t1",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: h }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: "t2",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "extract",
|
||||
arguments: JSON.stringify({ seen: "needle" }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const reactor = createTestReactor();
|
||||
const text = `## Agent Output\n${h}\n## Extraction Instruction\nExtract seen from CAS.`;
|
||||
const result = await reactor({
|
||||
thread: { cas },
|
||||
input: text,
|
||||
schema: metaSchema,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ seen: "needle" });
|
||||
expect(round).toBe(2);
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stops after max tool rounds when model keeps calling cas_get", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "thread-reactor-max-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const blob = serializeMerkleNode(createContentMerkleNode("x"));
|
||||
const h = await cas.put(blob);
|
||||
|
||||
const origFetch = globalThis.fetch;
|
||||
let round = 0;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
round += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
id: `loop-${round}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cas_get",
|
||||
arguments: JSON.stringify({ hash: h }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
},
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const reactor = createTestReactor();
|
||||
const result = await reactor({
|
||||
thread: { cas },
|
||||
input: "## Agent Output\nnoop\n## Extraction Instruction\nExtract seen.",
|
||||
schema: metaSchema,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.error).toBe("max_react_rounds_exceeded");
|
||||
expect(round).toBe(10);
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("passthrough JSON assistant message without tool calls", async () => {
|
||||
const casDir = await mkdtemp(join(tmpdir(), "thread-reactor-pass-"));
|
||||
const cas = createCasStore(casDir);
|
||||
try {
|
||||
const origFetch = globalThis.fetch;
|
||||
restoreFetch = () => {
|
||||
globalThis.fetch = origFetch;
|
||||
};
|
||||
globalThis.fetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '{"seen":"direct"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
{ preconnect: origFetch.preconnect.bind(origFetch) },
|
||||
) as typeof fetch;
|
||||
|
||||
const reactor = createTestReactor();
|
||||
const result = await reactor({
|
||||
thread: { cas },
|
||||
input: "## Agent Output\nok\n## Extraction Instruction\nExtract.",
|
||||
schema: metaSchema,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.value).toEqual({ seen: "direct" });
|
||||
} finally {
|
||||
await rm(casDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { decodeCrockfordBase32Bits } from "../src/util/base32.js";
|
||||
import { generateUlid } from "../src/util/ulid.js";
|
||||
|
||||
describe("generateUlid", () => {
|
||||
test("length and decodable Crockford payload", () => {
|
||||
const id = generateUlid(1_714_963_200_000);
|
||||
expect(id.length).toBe(26);
|
||||
const decoded = decodeCrockfordBase32Bits(id, 128);
|
||||
expect(decoded.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("embeds 48-bit timestamp at the MSB of the 128-bit payload", () => {
|
||||
const ts = 9_999_888_777_666;
|
||||
const id = generateUlid(ts);
|
||||
const decoded = decodeCrockfordBase32Bits(id, 128);
|
||||
expect(decoded.ok).toBe(true);
|
||||
if (decoded.ok) {
|
||||
const recoveredMs = decoded.value >> 80n;
|
||||
expect(Number(recoveredMs)).toBe(ts);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects out-of-range timestamps", () => {
|
||||
expect(() => generateUlid(-1)).toThrow();
|
||||
expect(() => generateUlid(2 ** 48)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { createContentMerkleNode, serializeMerkleNode } from "../src/cas/merkle.js";
|
||||
import { getWorkerHostScriptPath } from "../src/engine/worker-entry-path.js";
|
||||
|
||||
const WORKER_REGISTRY_YAML = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/model
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const bundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "worker-test",
|
||||
roles: {
|
||||
planner: { description: "planner", schema: {} },
|
||||
coder: { description: "coder", schema: {} },
|
||||
},
|
||||
};
|
||||
export const run = async function* (input, options) {
|
||||
const cas = options.cas;
|
||||
const has = (r) => input.steps.some((s) => s.role === r);
|
||||
if (!has("planner")) {
|
||||
const h = await putContentMerkleNode(cas, "p");
|
||||
yield { role: "planner", contentHash: h, meta: { plan: input.prompt }, refs: [h] };
|
||||
}
|
||||
if (!has("coder")) {
|
||||
const h = await putContentMerkleNode(cas, "c");
|
||||
yield { role: "coder", contentHash: h, meta: { diff: "y" }, refs: [h] };
|
||||
}
|
||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||
};
|
||||
`;
|
||||
|
||||
async function readReadyPort(child: import("node:child_process").ChildProcess): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
if (child.stdout === null) {
|
||||
reject(new Error("missing stdout"));
|
||||
return;
|
||||
}
|
||||
|
||||
let buf = "";
|
||||
function cleanup(): void {
|
||||
child.stdout?.off("data", onData);
|
||||
child.off("exit", onExit);
|
||||
}
|
||||
|
||||
function onData(chunk: Buffer): void {
|
||||
buf += chunk.toString("utf8");
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl < 0) {
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
const line = buf.slice(0, nl).trim();
|
||||
const prefix = "READY ";
|
||||
if (!line.startsWith(prefix)) {
|
||||
reject(new Error(`unexpected READY line: ${line}`));
|
||||
return;
|
||||
}
|
||||
resolve(Number(line.slice(prefix.length)));
|
||||
}
|
||||
|
||||
function onExit(code: number | null): void {
|
||||
cleanup();
|
||||
reject(new Error(`worker exited before READY (code ${code})`));
|
||||
}
|
||||
|
||||
child.stdout.on("data", onData);
|
||||
child.on("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendJson(port: number, payload: unknown): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = createConnection({ host: "127.0.0.1", port }, () => {
|
||||
socket.write(`${JSON.stringify(payload)}\n`);
|
||||
socket.end();
|
||||
});
|
||||
socket.on("error", reject);
|
||||
socket.on("close", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
describe("worker process", () => {
|
||||
test("loads bundle, runs a thread over TCP, then exits when idle", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-worker-"));
|
||||
try {
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
await mkdir(join(root, "bundles"), { recursive: true });
|
||||
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
|
||||
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
||||
await writeFile(bundlePath, bundleSource, "utf8");
|
||||
|
||||
const scriptPath = getWorkerHostScriptPath();
|
||||
const child = spawn(process.execPath, [scriptPath, bundlePath, root, hash], {
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
|
||||
if (child.stdout === null) {
|
||||
throw new Error("missing stdout");
|
||||
}
|
||||
|
||||
const port = await readReadyPort(child);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
await sendJson(port, {
|
||||
type: "run",
|
||||
threadId,
|
||||
workflowName: "demo-flow",
|
||||
prompt: "hello",
|
||||
options: { maxRounds: 5, depth: 0 },
|
||||
});
|
||||
|
||||
const exitCode: number = await new Promise((resolve) => {
|
||||
child.on("exit", (code) => resolve(code ?? 1));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
expect(
|
||||
text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "").length,
|
||||
).toBe(4);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test("run with historical steps + forkSourceThreadId replays then continues", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-worker-fork-"));
|
||||
try {
|
||||
const hash = "C9NMV6V2TQT81";
|
||||
await mkdir(join(root, "bundles"), { recursive: true });
|
||||
await writeFile(join(root, "workflow.yaml"), WORKER_REGISTRY_YAML, "utf8");
|
||||
const bundlePath = join(root, "bundles", `${hash}.esm.js`);
|
||||
await writeFile(bundlePath, bundleSource, "utf8");
|
||||
|
||||
const scriptPath = getWorkerHostScriptPath();
|
||||
const child = spawn(process.execPath, [scriptPath, bundlePath, root, hash], {
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
|
||||
if (child.stdout === null) {
|
||||
throw new Error("missing stdout");
|
||||
}
|
||||
|
||||
const port = await readReadyPort(child);
|
||||
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const plannerReplayHash = await cas.put(
|
||||
serializeMerkleNode(createContentMerkleNode("p-old")),
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const srcId = "01SRCMMMMMMMMMMMMMMMMMMMM";
|
||||
await sendJson(port, {
|
||||
type: "run",
|
||||
threadId,
|
||||
workflowName: "demo-flow",
|
||||
prompt: "hello",
|
||||
options: { maxRounds: 5, depth: 0 },
|
||||
steps: [
|
||||
{
|
||||
role: "planner",
|
||||
contentHash: plannerReplayHash,
|
||||
meta: { plan: "z" },
|
||||
refs: [plannerReplayHash],
|
||||
timestamp: 555,
|
||||
},
|
||||
],
|
||||
forkSourceThreadId: srcId,
|
||||
});
|
||||
|
||||
const exitCode: number = await new Promise((resolve) => {
|
||||
child.on("exit", (code) => resolve(code ?? 1));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const text = await readFile(dataPath, "utf8");
|
||||
const lines = text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(lines.length).toBe(4);
|
||||
const start = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
expect(start.forkFrom).toEqual({ threadId: srcId });
|
||||
const replay = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(replay.role).toBe("planner");
|
||||
expect(replay.timestamp).toBe(555);
|
||||
const coder = JSON.parse(lines[2] ?? "{}") as Record<string, unknown>;
|
||||
expect(coder.role).toBe("coder");
|
||||
const done = JSON.parse(lines[3] ?? "{}") as Record<string, unknown>;
|
||||
expect(done.returnCode).toBe(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { END } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||
import { getContentMerklePayload, parseMerkleNode } from "../src/cas/merkle.js";
|
||||
import { createWorkflow } from "../src/engine/create-workflow.js";
|
||||
import { executeThread } from "../src/engine/engine.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry/registry.js";
|
||||
import { createLogger } from "../src/util/logger.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
const callerMetaSchema = z.object({ done: z.literal(true) });
|
||||
|
||||
type ParentMeta = {
|
||||
caller: z.infer<typeof callerMetaSchema>;
|
||||
};
|
||||
|
||||
function installMockChatCompletions(sequence: ReadonlyArray<Record<string, unknown>>): () => void {
|
||||
const origFetch = globalThis.fetch;
|
||||
let i = 0;
|
||||
const mockFetch = async (
|
||||
_input: Parameters<typeof fetch>[0],
|
||||
_init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const args = sequence[i] ?? sequence[sequence.length - 1];
|
||||
if (args === undefined) {
|
||||
throw new Error("installMockChatCompletions: empty sequence");
|
||||
}
|
||||
i += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [{ message: { content: JSON.stringify(args) } }],
|
||||
}),
|
||||
{ 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 PARENT_REGISTRY_WITH_CONFIG = `config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`;
|
||||
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "child-integration",
|
||||
roles: {
|
||||
agent: {
|
||||
description: "agent",
|
||||
schema: { type: "object", properties: {}, additionalProperties: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function* run(thread, runtime) {
|
||||
const cas = runtime.cas;
|
||||
const h = await putContentMerkleNode(cas, "child-body");
|
||||
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "child-done:" + thread.start.content };
|
||||
}
|
||||
`;
|
||||
|
||||
async function installChildWorkflow(storageRoot: string): Promise<{ hash: string }> {
|
||||
const bytes = new TextEncoder().encode(childBundleSource);
|
||||
const hash = hashWorkflowBundleBytes(bytes);
|
||||
await mkdir(join(storageRoot, "bundles"), { recursive: true });
|
||||
await writeFile(join(storageRoot, "bundles", `${hash}.esm.js`), childBundleSource, "utf8");
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
throw reg.error;
|
||||
}
|
||||
const next = registerWorkflowVersion(reg.value, "child-wf", hash, Date.now());
|
||||
const wr = await writeWorkflowRegistry(storageRoot, next);
|
||||
if (!wr.ok) {
|
||||
throw wr.error;
|
||||
}
|
||||
return { hash };
|
||||
}
|
||||
|
||||
describe("workflowAsAgent integration", () => {
|
||||
let restoreFetch: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch?.();
|
||||
restoreFetch = null;
|
||||
});
|
||||
|
||||
test("createWorkflow parent invokes nested workflow via workflowAsAgent", async () => {
|
||||
restoreFetch = installMockChatCompletions([{ done: true }]);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-int-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(join(root, "workflow.yaml"), PARENT_REGISTRY_WITH_CONFIG, "utf8");
|
||||
const { hash: childHash } = await installChildWorkflow(root);
|
||||
|
||||
const parentWorkflow = createWorkflow<ParentMeta>(
|
||||
{
|
||||
roles: {
|
||||
caller: {
|
||||
description: "delegates to child workflow",
|
||||
systemPrompt: "system",
|
||||
extractPrompt: "extract done flag",
|
||||
schema: callerMetaSchema,
|
||||
extractRefs: null,
|
||||
},
|
||||
},
|
||||
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
|
||||
},
|
||||
{ agent: workflowAsAgent("child-wf", { storageRoot: root }), overrides: null },
|
||||
);
|
||||
|
||||
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
|
||||
const parentHash = "C9NMV6V2TQT81";
|
||||
const dataPath = join(root, "logs", parentHash, `${threadId}.data.jsonl`);
|
||||
const infoPath = join(root, "logs", parentHash, `${threadId}.info.jsonl`);
|
||||
await mkdir(join(root, "logs", parentHash), { recursive: true });
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
|
||||
const ac = new AbortController();
|
||||
|
||||
const result = await executeThread(
|
||||
parentWorkflow,
|
||||
"parent-wf",
|
||||
{ prompt: "from-parent", steps: [] },
|
||||
{
|
||||
maxRounds: 5,
|
||||
depth: 0,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: null,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot: root,
|
||||
},
|
||||
{ threadId, hash: parentHash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result.returnCode).toBe(0);
|
||||
expect(typeof result.rootHash).toBe("string");
|
||||
|
||||
const parentText = await readFile(dataPath, "utf8");
|
||||
const parentLines = parentText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "");
|
||||
expect(parentLines.length).toBe(2);
|
||||
const callerLine = JSON.parse(parentLines[1] ?? "{}") as Record<string, unknown>;
|
||||
expect(callerLine.role).toBe("caller");
|
||||
const childRootHash = await getContentMerklePayload(cas, String(callerLine.contentHash));
|
||||
expect(childRootHash).not.toBeNull();
|
||||
const childThreadYaml = await cas.get(childRootHash ?? "");
|
||||
expect(childThreadYaml).not.toBeNull();
|
||||
const childThreadNode = parseMerkleNode(childThreadYaml ?? "");
|
||||
expect(childThreadNode.type).toBe("thread");
|
||||
const childPayload = childThreadNode.payload as Record<string, unknown>;
|
||||
expect(childPayload.workflow).toBe("child-wf");
|
||||
const childResult = childPayload.result as Record<string, unknown>;
|
||||
expect(childResult.summary).toBe("child-done:from-parent");
|
||||
|
||||
const childDir = join(root, "logs", childHash);
|
||||
const childFiles = await readdir(childDir);
|
||||
const childDataName = childFiles.find((n) => n.endsWith(".data.jsonl"));
|
||||
expect(childDataName).toBeDefined();
|
||||
|
||||
const childText = await readFile(join(childDir, childDataName ?? ""), "utf8");
|
||||
const childStart = JSON.parse(
|
||||
childText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((l) => l !== "")[0] ?? "{}",
|
||||
) as Record<string, unknown>;
|
||||
expect(childStart.forkFrom).toEqual({ threadId });
|
||||
const childOpts = (childStart.parameters as Record<string, unknown>).options as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(childOpts.depth).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,187 +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 { type AgentContext, START } from "@uncaged/workflow-runtime";
|
||||
import { createCasStore } from "../src/cas/cas.js";
|
||||
import { hashWorkflowBundleBytes } from "../src/cas/hash.js";
|
||||
import { parseMerkleNode } from "../src/cas/merkle.js";
|
||||
import {
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
writeWorkflowRegistry,
|
||||
} from "../src/registry/registry.js";
|
||||
import { workflowAsAgent } from "../src/workflow-as-agent.js";
|
||||
|
||||
function makeAgentCtx(params: {
|
||||
storageRoot: string;
|
||||
depth: number;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
}): AgentContext {
|
||||
const ts = Date.now();
|
||||
return {
|
||||
threadId: "01PARENT000000000000000001AA",
|
||||
depth: params.depth,
|
||||
start: {
|
||||
role: START,
|
||||
content: params.prompt,
|
||||
meta: { maxRounds: params.maxRounds },
|
||||
timestamp: ts,
|
||||
},
|
||||
steps: [],
|
||||
currentRole: {
|
||||
name: "caller",
|
||||
systemPrompt: "caller",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const childBundleSource = `import { putContentMerkleNode } from "@uncaged/workflow";
|
||||
|
||||
export const descriptor = {
|
||||
description: "child-test",
|
||||
roles: {
|
||||
agent: {
|
||||
description: "agent",
|
||||
schema: { type: "object", properties: {}, additionalProperties: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
export async function* run(thread, runtime) {
|
||||
const cas = runtime.cas;
|
||||
const h = await putContentMerkleNode(cas, "child-body");
|
||||
yield { role: "agent", contentHash: h, meta: {}, refs: [h] };
|
||||
return { returnCode: 0, summary: "child-done:" + thread.start.content };
|
||||
}
|
||||
`;
|
||||
|
||||
async function installChildWorkflow(storageRoot: string): Promise<{ hash: string }> {
|
||||
const bytes = new TextEncoder().encode(childBundleSource);
|
||||
const hash = hashWorkflowBundleBytes(bytes);
|
||||
await mkdir(join(storageRoot, "bundles"), { recursive: true });
|
||||
await writeFile(join(storageRoot, "bundles", `${hash}.esm.js`), childBundleSource, "utf8");
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
throw reg.error;
|
||||
}
|
||||
const next = registerWorkflowVersion(reg.value, "child-wf", hash, Date.now());
|
||||
const wr = await writeWorkflowRegistry(storageRoot, next);
|
||||
if (!wr.ok) {
|
||||
throw wr.error;
|
||||
}
|
||||
return { hash };
|
||||
}
|
||||
|
||||
describe("workflowAsAgent", () => {
|
||||
test("returns error when workflow name is not registered", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-missing-"));
|
||||
try {
|
||||
const agent = workflowAsAgent("missing-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 0, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(out).toContain("not found in registry");
|
||||
expect(out).toContain("missing-wf");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runs registered workflow and returns child thread root CAS hash", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-ok-"));
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "workflow.yaml"),
|
||||
`config:
|
||||
maxDepth: 3
|
||||
providers:
|
||||
stub:
|
||||
baseUrl: http://127.0.0.1:9
|
||||
apiKey: test
|
||||
models:
|
||||
default: stub/m
|
||||
workflows: {}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await installChildWorkflow(root);
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 0, prompt: "hello-parent", maxRounds: 5 }),
|
||||
);
|
||||
const cas = createCasStore(join(root, "cas"));
|
||||
const threadYaml = await cas.get(out);
|
||||
expect(threadYaml).not.toBeNull();
|
||||
const node = parseMerkleNode(threadYaml ?? "");
|
||||
expect(node.type).toBe("thread");
|
||||
const payload = node.payload as Record<string, unknown>;
|
||||
expect(payload.workflow).toBe("child-wf");
|
||||
const resultObj = payload.result as Record<string, unknown>;
|
||||
expect(resultObj.summary).toBe("child-done:hello-parent");
|
||||
expect(node.children.length).toBe(1);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("enforces depth limit (returns error string, does not throw)", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-depth-"));
|
||||
try {
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const out = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(out).toContain("depth limit");
|
||||
expect(out).toContain("max 3");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses registry config maxDepth when set", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "wf-waa-maxdepth-cfg-"));
|
||||
try {
|
||||
await installChildWorkflow(root);
|
||||
const reg = await readWorkflowRegistry(root);
|
||||
expect(reg.ok).toBe(true);
|
||||
if (!reg.ok) {
|
||||
return;
|
||||
}
|
||||
const withCfg = {
|
||||
...reg.value,
|
||||
config: {
|
||||
maxDepth: 2,
|
||||
supervisorInterval: 3,
|
||||
providers: {
|
||||
local: {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "k",
|
||||
},
|
||||
},
|
||||
models: {
|
||||
default: "local/m",
|
||||
extract: "local/m",
|
||||
},
|
||||
},
|
||||
};
|
||||
const wr = await writeWorkflowRegistry(root, withCfg);
|
||||
expect(wr.ok).toBe(true);
|
||||
|
||||
const agent = workflowAsAgent("child-wf", { storageRoot: root });
|
||||
const okOut = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 1, prompt: "nest-once", maxRounds: 5 }),
|
||||
);
|
||||
expect(okOut).not.toContain("depth limit");
|
||||
|
||||
const badOut = await agent(
|
||||
makeAgentCtx({ storageRoot: root, depth: 2, prompt: "x", maxRounds: 5 }),
|
||||
);
|
||||
expect(badOut).toContain("depth limit");
|
||||
expect(badOut).toContain("max 2");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateWorkflowDescriptor } from "../src/bundle/workflow-descriptor.js";
|
||||
|
||||
describe("validateWorkflowDescriptor", () => {
|
||||
// 1. Valid minimal descriptor
|
||||
test("accepts a minimal descriptor with empty roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: {} });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.description).toBe("x");
|
||||
expect(result.value.roles).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Valid descriptor with one role
|
||||
test("accepts a descriptor with one role", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "workflow",
|
||||
roles: {
|
||||
solver: { description: "solves things", schema: { type: "object" } },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.description).toBe("workflow");
|
||||
expect(result.value.roles.solver.description).toBe("solves things");
|
||||
expect(result.value.roles.solver.schema).toEqual({ type: "object" });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Valid descriptor with multiple roles
|
||||
test("accepts a descriptor with multiple roles", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "multi",
|
||||
roles: {
|
||||
a: { description: "role a", schema: {} },
|
||||
b: { description: "role b", schema: { type: "string" } },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(Object.keys(result.value.roles)).toEqual(["a", "b"]);
|
||||
}
|
||||
});
|
||||
|
||||
// 4-6. Root is null / array / string / number / undefined
|
||||
test("rejects null", () => {
|
||||
const result = validateWorkflowDescriptor(null);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects an array", () => {
|
||||
const result = validateWorkflowDescriptor([]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects a string", () => {
|
||||
const result = validateWorkflowDescriptor("hello");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects a number", () => {
|
||||
const result = validateWorkflowDescriptor(42);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects undefined", () => {
|
||||
const result = validateWorkflowDescriptor(undefined);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor must be a non-array object");
|
||||
});
|
||||
|
||||
// 7-8. Missing or non-string description
|
||||
test("rejects missing description", () => {
|
||||
const result = validateWorkflowDescriptor({ roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects numeric description", () => {
|
||||
const result = validateWorkflowDescriptor({ description: 123, roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects null description", () => {
|
||||
const result = validateWorkflowDescriptor({ description: null, roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects boolean description", () => {
|
||||
const result = validateWorkflowDescriptor({ description: true, roles: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.description must be a string");
|
||||
});
|
||||
|
||||
// 9-11. Missing / null / array roles
|
||||
test("rejects missing roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects null roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: null });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects array roles", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: [] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles must be a non-array object");
|
||||
});
|
||||
|
||||
// 12-13. Role entry is null / array
|
||||
test("rejects null role entry", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: { bad: null } });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.bad must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects array role entry", () => {
|
||||
const result = validateWorkflowDescriptor({ description: "x", roles: { bad: [] } });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.bad must be a non-array object");
|
||||
});
|
||||
|
||||
// 14-15. Role missing description / non-string description
|
||||
test("rejects role with missing description", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { schema: {} } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.r.description must be a string");
|
||||
});
|
||||
|
||||
test("rejects role with non-string description", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: 99, schema: {} } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.r.description must be a string");
|
||||
});
|
||||
|
||||
// 16-18. Role schema null / array / missing
|
||||
test("rejects role with null schema", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: "d", schema: null } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBe("descriptor.roles.r.schema must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects role with array schema", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: "d", schema: [] } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBe("descriptor.roles.r.schema must be a non-array object");
|
||||
});
|
||||
|
||||
test("rejects role with missing schema", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: { r: { description: "d" } },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBe("descriptor.roles.r.schema must be a non-array object");
|
||||
});
|
||||
|
||||
// 19. First role valid, second role invalid
|
||||
test("rejects at first invalid role when earlier roles are valid", () => {
|
||||
const result = validateWorkflowDescriptor({
|
||||
description: "x",
|
||||
roles: {
|
||||
good: { description: "ok", schema: {} },
|
||||
bad: { description: 123, schema: {} },
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe("descriptor.roles.bad.description must be a string");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@uncaged/workflow",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@uncaged/workflow-runtime": "workspace:*",
|
||||
"acorn": "^8.16.0",
|
||||
"xxhashjs": "^0.2.2",
|
||||
"yaml": "^2.8.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/acorn": "^6.0.4",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-runtime";
|
||||
import * as z from "zod/v4";
|
||||
import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js";
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): WorkflowRoleSchema {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest as WorkflowRoleSchema;
|
||||
}
|
||||
|
||||
export function buildDescriptor<M extends RoleMeta>(
|
||||
def: WorkflowDefinition<M>,
|
||||
): WorkflowDescriptor {
|
||||
const roles: WorkflowDescriptor["roles"] = {};
|
||||
for (const [key, roleDef] of Object.entries(def.roles) as Array<
|
||||
[string, { description: string; schema: z.ZodType }]
|
||||
>) {
|
||||
const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record<string, unknown>;
|
||||
roles[key] = {
|
||||
description: roleDef.description,
|
||||
schema: stripJsonSchemaMeta(rawJsonSchema),
|
||||
};
|
||||
}
|
||||
return { description: def.description, roles };
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
/**
|
||||
* Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first).
|
||||
*/
|
||||
export async function importWorkflowBundleModule(bundlePath: string): Promise<unknown> {
|
||||
return import(pathToFileURL(bundlePath).href);
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { isBuiltin } from "node:module";
|
||||
import type {
|
||||
CallExpression,
|
||||
ExportAllDeclaration,
|
||||
ExportNamedDeclaration,
|
||||
ExportSpecifier,
|
||||
FunctionDeclaration,
|
||||
ImportDeclaration,
|
||||
Node,
|
||||
Program,
|
||||
VariableDeclaration,
|
||||
} from "acorn";
|
||||
import * as acorn from "acorn";
|
||||
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { WorkflowBundleValidationInput } from "./types.js";
|
||||
|
||||
/** Acorn Node with index-access for property traversal. */
|
||||
type AcornNode = Node & { [key: string]: unknown };
|
||||
|
||||
/**
|
||||
* Narrow an Acorn Node to a specific AST subtype after a `.type` guard.
|
||||
* Avoids double-cast (`as unknown as T`) by going through AcornNode.
|
||||
*/
|
||||
function narrowNode<T extends Node>(node: Node): T {
|
||||
return node as unknown as T;
|
||||
}
|
||||
|
||||
function endsWithEsmJs(path: string): boolean {
|
||||
return path.endsWith(".esm.js");
|
||||
}
|
||||
|
||||
function isAllowedImportSpecifier(spec: string): boolean {
|
||||
if (spec.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) {
|
||||
return false;
|
||||
}
|
||||
if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") {
|
||||
return true;
|
||||
}
|
||||
return isBuiltin(spec);
|
||||
}
|
||||
|
||||
function pushNestedAstNodes(value: unknown, out: Node[]): void {
|
||||
if (value === null || value === undefined) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item !== null && typeof item === "object" && "type" in item) {
|
||||
out.push(item as Node);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object" && "type" in value) {
|
||||
out.push(value as Node);
|
||||
}
|
||||
}
|
||||
|
||||
function collectChildNodes(node: Node): Node[] {
|
||||
const children: Node[] = [];
|
||||
for (const key of Object.keys(node)) {
|
||||
const val = (node as AcornNode)[key];
|
||||
pushNestedAstNodes(val, children);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function walkAst(node: Node, visit: (n: Node) => void): void {
|
||||
visit(node);
|
||||
for (const child of collectChildNodes(node)) {
|
||||
walkAst(child, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function exportSpecifierExportedName(spec: ExportSpecifier): string | null {
|
||||
if (spec.exported.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
return spec.exported.name;
|
||||
}
|
||||
|
||||
function exportNamedDeclReExportsDefault(named: ExportNamedDeclaration): boolean {
|
||||
if (named.source !== null && named.source !== undefined) {
|
||||
return false;
|
||||
}
|
||||
return named.specifiers.some(
|
||||
(spec) => spec.type === "ExportSpecifier" && exportSpecifierExportedName(spec) === "default",
|
||||
);
|
||||
}
|
||||
|
||||
function programUsesDefaultExport(program: Program): boolean {
|
||||
for (const stmt of program.body) {
|
||||
if (stmt.type === "ExportDefaultDeclaration") {
|
||||
return true;
|
||||
}
|
||||
if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclReExportsDefault(stmt)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bindingInitializerIsCallable(init: Node): boolean {
|
||||
return (
|
||||
init.type === "FunctionExpression" ||
|
||||
init.type === "ArrowFunctionExpression" ||
|
||||
init.type === "CallExpression"
|
||||
);
|
||||
}
|
||||
|
||||
function variableDeclarationBindsCallableName(stmt: VariableDeclaration, name: string): boolean {
|
||||
for (const decl of stmt.declarations) {
|
||||
if (decl.id.type !== "Identifier" || decl.id.name !== name) {
|
||||
continue;
|
||||
}
|
||||
const init = decl.init;
|
||||
if (init === null || init === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (bindingInitializerIsCallable(init)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function programDeclaresCallableExportBinding(program: Program, name: string): boolean {
|
||||
for (const stmt of program.body) {
|
||||
if (stmt.type === "FunctionDeclaration") {
|
||||
const fd = stmt as FunctionDeclaration;
|
||||
const id = fd.id;
|
||||
if (id !== null && id !== undefined && id.type === "Identifier" && id.name === name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (stmt.type === "VariableDeclaration" && variableDeclarationBindsCallableName(stmt, name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function namedExportDeclExportsRunCallable(named: ExportNamedDeclaration): boolean {
|
||||
const decl = named.declaration;
|
||||
if (decl === null || decl === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (decl.type === "FunctionDeclaration") {
|
||||
const id = decl.id;
|
||||
return id !== null && id !== undefined && id.type === "Identifier" && id.name === "run";
|
||||
}
|
||||
if (decl.type === "VariableDeclaration") {
|
||||
return variableDeclarationBindsCallableName(decl, "run");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findRunExportLocalBindingName(program: Program): string | null {
|
||||
for (const stmt of program.body) {
|
||||
if (stmt.type !== "ExportNamedDeclaration") {
|
||||
continue;
|
||||
}
|
||||
const named = stmt as ExportNamedDeclaration;
|
||||
if (named.source !== null && named.source !== undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const spec of named.specifiers) {
|
||||
if (spec.type !== "ExportSpecifier" || exportSpecifierExportedName(spec) !== "run") {
|
||||
continue;
|
||||
}
|
||||
const loc = spec.local;
|
||||
if (loc.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
return loc.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function runExportIsCallable(program: Program): boolean {
|
||||
for (const stmt of program.body) {
|
||||
if (stmt.type === "ExportNamedDeclaration") {
|
||||
const named = stmt as ExportNamedDeclaration;
|
||||
if (namedExportDeclExportsRunCallable(named)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const exportBinding = findRunExportLocalBindingName(program);
|
||||
if (exportBinding !== null) {
|
||||
return programDeclaresCallableExportBinding(program, exportBinding);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function namedExportDeclExportsDescriptor(named: ExportNamedDeclaration): boolean {
|
||||
const decl = named.declaration;
|
||||
if (decl === null || decl === undefined || decl.type !== "VariableDeclaration") {
|
||||
return false;
|
||||
}
|
||||
for (const d of decl.declarations) {
|
||||
if (d.id.type === "Identifier" && d.id.name === "descriptor") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function functionDeclarationNamed(stmt: FunctionDeclaration, name: string): boolean {
|
||||
const id = stmt.id;
|
||||
return id !== null && id !== undefined && id.type === "Identifier" && id.name === name;
|
||||
}
|
||||
|
||||
function variableDeclarationNames(stmt: VariableDeclaration, name: string): boolean {
|
||||
for (const decl of stmt.declarations) {
|
||||
if (decl.id.type === "Identifier" && decl.id.name === name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function programDeclaresBindingName(program: Program, name: string): boolean {
|
||||
for (const stmt of program.body) {
|
||||
if (
|
||||
stmt.type === "FunctionDeclaration" &&
|
||||
functionDeclarationNamed(stmt as FunctionDeclaration, name)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (stmt.type === "VariableDeclaration" && variableDeclarationNames(stmt, name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findDescriptorExportLocalBindingName(program: Program): string | null {
|
||||
for (const stmt of program.body) {
|
||||
if (stmt.type !== "ExportNamedDeclaration") {
|
||||
continue;
|
||||
}
|
||||
const named = stmt as ExportNamedDeclaration;
|
||||
if (named.source !== null && named.source !== undefined) {
|
||||
continue;
|
||||
}
|
||||
for (const spec of named.specifiers) {
|
||||
if (spec.type !== "ExportSpecifier" || exportSpecifierExportedName(spec) !== "descriptor") {
|
||||
continue;
|
||||
}
|
||||
const loc = spec.local;
|
||||
if (loc.type !== "Identifier") {
|
||||
return null;
|
||||
}
|
||||
return loc.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function descriptorExportExists(program: Program): boolean {
|
||||
for (const stmt of program.body) {
|
||||
if (stmt.type === "ExportNamedDeclaration") {
|
||||
const named = stmt as ExportNamedDeclaration;
|
||||
if (namedExportDeclExportsDescriptor(named)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const binding = findDescriptorExportLocalBindingName(program);
|
||||
if (binding === null) {
|
||||
return false;
|
||||
}
|
||||
return programDeclaresBindingName(program, binding);
|
||||
}
|
||||
|
||||
function stringLiteralModuleSpecifier(src: Node): string | null {
|
||||
if (src.type !== "Literal" || typeof (src as AcornNode).value !== "string") {
|
||||
return null;
|
||||
}
|
||||
return (src as AcornNode).value as string;
|
||||
}
|
||||
|
||||
function validateImportDeclaration(node: ImportDeclaration): string | null {
|
||||
const spec = stringLiteralModuleSpecifier(node.source);
|
||||
if (spec === null) {
|
||||
return "only static string import specifiers are allowed";
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `disallowed import specifier "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateExportSource(
|
||||
src: Node,
|
||||
staticMessage: string,
|
||||
disallowedPrefix: string,
|
||||
): string | null {
|
||||
const spec = stringLiteralModuleSpecifier(src);
|
||||
if (spec === null) {
|
||||
return staticMessage;
|
||||
}
|
||||
if (!isAllowedImportSpecifier(spec)) {
|
||||
return `${disallowedPrefix} "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateExportNamedDeclaration(node: ExportNamedDeclaration): string | null {
|
||||
if (node.source === null || node.source === undefined) {
|
||||
return null;
|
||||
}
|
||||
return validateExportSource(
|
||||
node.source,
|
||||
"only static string re-export specifiers are allowed",
|
||||
"disallowed re-export specifier",
|
||||
);
|
||||
}
|
||||
|
||||
function validateExportAllDeclaration(node: ExportAllDeclaration): string | null {
|
||||
return validateExportSource(
|
||||
node.source,
|
||||
"only static string export-all specifiers are allowed",
|
||||
"disallowed export-all specifier",
|
||||
);
|
||||
}
|
||||
|
||||
function validateRequireCall(node: CallExpression): string | null {
|
||||
const callee = node.callee;
|
||||
if (callee.type === "Identifier" && callee.name === "require") {
|
||||
return "require() is not allowed in workflow bundles";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bundleConstraintViolationForNode(node: Node): string | null {
|
||||
if (node.type === "ImportExpression") {
|
||||
return "dynamic import() is not allowed in workflow bundles";
|
||||
}
|
||||
if (node.type === "ImportDeclaration") {
|
||||
return validateImportDeclaration(narrowNode<ImportDeclaration>(node));
|
||||
}
|
||||
if (node.type === "ExportNamedDeclaration") {
|
||||
return validateExportNamedDeclaration(narrowNode<ExportNamedDeclaration>(node));
|
||||
}
|
||||
if (node.type === "ExportAllDeclaration") {
|
||||
return validateExportAllDeclaration(narrowNode<ExportAllDeclaration>(node));
|
||||
}
|
||||
if (node.type === "CallExpression") {
|
||||
return validateRequireCall(narrowNode<CallExpression>(node));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate RFC-001 bundle rules: single-file ESM shape, named exports `run` + `descriptor`,
|
||||
* no default export, no dynamic `import()`, static imports restricted to Node builtins plus `@uncaged/workflow`.
|
||||
*/
|
||||
export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result<void, string> {
|
||||
if (!endsWithEsmJs(input.filePath)) {
|
||||
return err('workflow bundle file must use the ".esm.js" suffix');
|
||||
}
|
||||
|
||||
let ast: Node;
|
||||
try {
|
||||
ast = acorn.parse(input.source, {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: "module",
|
||||
locations: false,
|
||||
}) as Node;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return err(`failed to parse module: ${message}`);
|
||||
}
|
||||
|
||||
if (ast.type !== "Program") {
|
||||
return err("internal error: expected Program root");
|
||||
}
|
||||
|
||||
const program = ast as Program;
|
||||
|
||||
if (programUsesDefaultExport(program)) {
|
||||
return err('workflow bundle must not use default export; use "export const run" instead');
|
||||
}
|
||||
|
||||
if (!runExportIsCallable(program)) {
|
||||
return err(
|
||||
'workflow bundle must export run as a callable (e.g. "export const run = async function* (...)")',
|
||||
);
|
||||
}
|
||||
|
||||
if (!descriptorExportExists(program)) {
|
||||
return err(
|
||||
'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")',
|
||||
);
|
||||
}
|
||||
|
||||
let violation: string | null = null;
|
||||
walkAst(ast, (node) => {
|
||||
if (violation !== null) {
|
||||
return;
|
||||
}
|
||||
const next = bundleConstraintViolationForNode(node);
|
||||
if (next !== null) {
|
||||
violation = next;
|
||||
}
|
||||
});
|
||||
|
||||
if (violation !== null) {
|
||||
return err(violation);
|
||||
}
|
||||
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { mkdir, readlink, symlink, unlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** This module lives in `@uncaged/workflow/src/bundle`; grandparent dir is the package root. */
|
||||
function installedWorkflowPackageDir(): string {
|
||||
return fileURLToPath(new URL("../..", import.meta.url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures `<storageRoot>/node_modules/@uncaged/workflow` points at the installed `@uncaged/workflow`
|
||||
* package so workflow bundles loaded from `<storageRoot>/bundles/*.esm.js` can resolve `import "@uncaged/workflow"`.
|
||||
*/
|
||||
export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise<void> {
|
||||
const target = installedWorkflowPackageDir();
|
||||
const linkDir = path.join(storageRoot, "node_modules", "@uncaged");
|
||||
const linkPath = path.join(linkDir, "workflow");
|
||||
await mkdir(linkDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const existing = await readlink(linkPath);
|
||||
const normalizedExisting = path.resolve(linkDir, existing);
|
||||
if (normalizedExisting === target) {
|
||||
return;
|
||||
}
|
||||
await unlink(linkPath);
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code !== "ENOENT" && errObj.code !== "EINVAL") {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const linkType = process.platform === "win32" ? "junction" : "dir";
|
||||
await symlink(target, linkPath, linkType);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
import { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js";
|
||||
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
|
||||
/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */
|
||||
export async function extractBundleExports(
|
||||
bundlePath: string,
|
||||
options: ExtractBundleExportsOptions = { storageRoot: null },
|
||||
): Promise<Result<ExtractedBundleExports, string>> {
|
||||
let modUnknown: unknown;
|
||||
try {
|
||||
if (options.storageRoot !== null) {
|
||||
await ensureUncagedWorkflowSymlink(options.storageRoot);
|
||||
}
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
modUnknown = await importWorkflowBundleModule(bundlePath);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return err(`failed to import bundle: ${message}`);
|
||||
}
|
||||
|
||||
const modRec = modUnknown as Record<string, unknown>;
|
||||
const defaultExport = modRec.default;
|
||||
if (defaultExport !== undefined) {
|
||||
return err("workflow bundle must not use default export; export const run instead");
|
||||
}
|
||||
|
||||
const run = modRec.run;
|
||||
if (typeof run !== "function") {
|
||||
return err("workflow bundle must export run as a function");
|
||||
}
|
||||
|
||||
const validated = validateWorkflowDescriptor(modRec.descriptor);
|
||||
if (!validated.ok) {
|
||||
return err(validated.error);
|
||||
}
|
||||
|
||||
return ok({ run: run as WorkflowFn, descriptor: validated.value });
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { stringify } from "yaml";
|
||||
|
||||
import type { WorkflowDescriptor } from "./types.js";
|
||||
|
||||
/** Serialize a validated workflow descriptor to YAML for storage next to the bundle. */
|
||||
export function stringifyWorkflowDescriptor(descriptor: WorkflowDescriptor): string {
|
||||
return stringify(descriptor, { indent: 2, defaultStringType: "QUOTE_DOUBLE" });
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export { buildDescriptor } from "./build-descriptor.js";
|
||||
export { importWorkflowBundleModule } from "./bundle-import-env.js";
|
||||
export { validateWorkflowBundle } from "./bundle-validator.js";
|
||||
export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js";
|
||||
export { extractBundleExports } from "./extract-bundle-exports.js";
|
||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||
export type {
|
||||
ExtractBundleExportsOptions,
|
||||
ExtractedBundleExports,
|
||||
WorkflowBundleValidationInput,
|
||||
WorkflowDescriptor,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "./types.js";
|
||||
export { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type {
|
||||
WorkflowDescriptor,
|
||||
WorkflowRoleDescriptor,
|
||||
WorkflowRoleSchema,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
|
||||
export type WorkflowBundleValidationInput = {
|
||||
/** Absolute or relative path (used for `.esm.js` suffix checks). */
|
||||
filePath: string;
|
||||
/** UTF-8 source of the bundle. */
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ExtractedBundleExports = {
|
||||
run: WorkflowFn;
|
||||
descriptor: WorkflowDescriptor;
|
||||
};
|
||||
|
||||
export type ExtractBundleExportsOptions = {
|
||||
/** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { mkdir, readdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { hashString } from "./hash.js";
|
||||
import { createContentMerkleNode, parseMerkleNode, serializeMerkleNode } from "./merkle.js";
|
||||
import type { CasStore } from "./types.js";
|
||||
|
||||
/** Raw strings become content merkle YAML; already-valid merkle documents pass through. */
|
||||
function normalizeCasPutContent(content: string): string {
|
||||
try {
|
||||
parseMerkleNode(content);
|
||||
return content;
|
||||
} catch {
|
||||
return serializeMerkleNode(createContentMerkleNode(content));
|
||||
}
|
||||
}
|
||||
|
||||
export function createCasStore(casDir: string): CasStore {
|
||||
async function ensureDir(): Promise<void> {
|
||||
await mkdir(casDir, { recursive: true });
|
||||
}
|
||||
|
||||
function filePath(hash: string): string {
|
||||
return join(casDir, `${hash}.txt`);
|
||||
}
|
||||
|
||||
return {
|
||||
async put(content: string): Promise<string> {
|
||||
const toStore = normalizeCasPutContent(content);
|
||||
const hash = hashString(toStore);
|
||||
await ensureDir();
|
||||
const target = filePath(hash);
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
await writeFile(tmp, toStore, "utf8");
|
||||
await rename(tmp, target);
|
||||
return hash;
|
||||
},
|
||||
|
||||
async get(hash: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath(hash), "utf8");
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async delete(hash: string): Promise<void> {
|
||||
try {
|
||||
await unlink(filePath(hash));
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
async list(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(casDir);
|
||||
return entries.filter((name) => name.endsWith(".txt")).map((name) => name.slice(0, -4));
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import XXH from "xxhashjs";
|
||||
|
||||
import { encodeUint64AsCrockford } from "../util/index.js";
|
||||
|
||||
function digestToUint64(digest: { toString(radix?: number): string }): bigint {
|
||||
const hex = digest.toString(16).padStart(16, "0");
|
||||
return BigInt(`0x${hex}`);
|
||||
}
|
||||
|
||||
/** XXH64 (seed 0) over bundle bytes, encoded as 13-char Crockford Base32. */
|
||||
export function hashWorkflowBundleBytes(data: Uint8Array): string {
|
||||
const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
||||
const digest = XXH.h64(0).update(buf).digest();
|
||||
return encodeUint64AsCrockford(digestToUint64(digest));
|
||||
}
|
||||
|
||||
/** XXH64 (seed 0) over a UTF-8 string, encoded as 13-char Crockford Base32. */
|
||||
export function hashString(content: string): string {
|
||||
const buf = Buffer.from(content, "utf8");
|
||||
const digest = XXH.h64(0).update(buf).digest();
|
||||
return encodeUint64AsCrockford(digestToUint64(digest));
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
export { createCasStore } from "./cas.js";
|
||||
export { hashString, hashWorkflowBundleBytes } from "./hash.js";
|
||||
export {
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
parseMerkleNode,
|
||||
putContentMerkleNode,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
serializeMerkleNode,
|
||||
} from "./merkle.js";
|
||||
export type {
|
||||
CasStore,
|
||||
MerkleNode,
|
||||
MerkleNodeType,
|
||||
StepMerklePayload,
|
||||
ThreadMerklePayload,
|
||||
} from "./types.js";
|
||||
@@ -1,99 +0,0 @@
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
import type { CasStore, MerkleNode, StepMerklePayload, ThreadMerklePayload } from "./types.js";
|
||||
|
||||
export function serializeMerkleNode(node: MerkleNode): string {
|
||||
return stringify(
|
||||
{ type: node.type, payload: node.payload, children: node.children },
|
||||
{ indent: 2 },
|
||||
);
|
||||
}
|
||||
|
||||
export function parseMerkleNode(yamlText: string): MerkleNode {
|
||||
const raw = parse(yamlText) as unknown;
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
throw new Error("merkle: YAML root must be an object");
|
||||
}
|
||||
const rec = raw as Record<string, unknown>;
|
||||
const type = rec.type;
|
||||
const payload = rec.payload;
|
||||
const children = rec.children;
|
||||
if (type !== "content" && type !== "step" && type !== "thread") {
|
||||
throw new Error("merkle: invalid or missing type");
|
||||
}
|
||||
if (typeof payload !== "string" && (payload === null || typeof payload !== "object")) {
|
||||
throw new Error("merkle: payload must be a string or object");
|
||||
}
|
||||
if (!Array.isArray(children)) {
|
||||
throw new Error("merkle: children must be an array");
|
||||
}
|
||||
const childHashes: string[] = [];
|
||||
for (const c of children) {
|
||||
if (typeof c !== "string") {
|
||||
throw new Error("merkle: child hash must be a string");
|
||||
}
|
||||
childHashes.push(c);
|
||||
}
|
||||
return {
|
||||
type,
|
||||
payload: typeof payload === "string" ? payload : (payload as Record<string, unknown>),
|
||||
children: childHashes,
|
||||
};
|
||||
}
|
||||
|
||||
export function createContentMerkleNode(payload: string): MerkleNode {
|
||||
return { type: "content", payload, children: [] };
|
||||
}
|
||||
|
||||
/** Serializes a step Merkle node (role + meta + content child) and stores it in CAS. */
|
||||
export async function putStepMerkleNode(
|
||||
store: CasStore,
|
||||
payload: StepMerklePayload,
|
||||
contentHash: string,
|
||||
): Promise<string> {
|
||||
const node: MerkleNode = {
|
||||
type: "step",
|
||||
payload: { role: payload.role, meta: payload.meta },
|
||||
children: [contentHash],
|
||||
};
|
||||
return store.put(serializeMerkleNode(node));
|
||||
}
|
||||
|
||||
/** Serializes the thread root Merkle node and stores it in CAS. */
|
||||
export async function putThreadMerkleNode(
|
||||
store: CasStore,
|
||||
payload: ThreadMerklePayload,
|
||||
stepHashes: readonly string[],
|
||||
): Promise<string> {
|
||||
const node: MerkleNode = {
|
||||
type: "thread",
|
||||
payload: {
|
||||
workflow: payload.workflow,
|
||||
threadId: payload.threadId,
|
||||
result: payload.result,
|
||||
},
|
||||
children: [...stepHashes],
|
||||
};
|
||||
return store.put(serializeMerkleNode(node));
|
||||
}
|
||||
|
||||
/** Stores agent/content text via CAS; {@link createCasStore} wraps raw strings as merkle content nodes. */
|
||||
export async function putContentMerkleNode(store: CasStore, content: string): Promise<string> {
|
||||
return store.put(content);
|
||||
}
|
||||
|
||||
/** Loads a CAS blob and returns the payload string for a `content` Merkle node. */
|
||||
export async function getContentMerklePayload(
|
||||
store: CasStore,
|
||||
hash: string,
|
||||
): Promise<string | null> {
|
||||
const yamlText = await store.get(hash);
|
||||
if (yamlText === null) {
|
||||
return null;
|
||||
}
|
||||
const node = parseMerkleNode(yamlText);
|
||||
if (node.type !== "content" || typeof node.payload !== "string") {
|
||||
return null;
|
||||
}
|
||||
return node.payload;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export type { CasStore } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type MerkleNodeType = "content" | "step" | "thread";
|
||||
|
||||
export type MerkleNode = {
|
||||
type: MerkleNodeType;
|
||||
payload: string | Record<string, unknown>;
|
||||
children: string[];
|
||||
};
|
||||
|
||||
export type StepMerklePayload = {
|
||||
role: string;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ThreadMerklePayload = {
|
||||
workflow: string;
|
||||
threadId: string;
|
||||
result: {
|
||||
returnCode: number;
|
||||
summary: string;
|
||||
};
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export { resolveModel } from "./resolve-model.js";
|
||||
export { splitProviderModelRef } from "./split-provider-model-ref.js";
|
||||
export type { ProviderConfig, ResolvedModel } from "./types.js";
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { WorkflowConfig } from "../registry/index.js";
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
import { splitProviderModelRef } from "./split-provider-model-ref.js";
|
||||
import type { ResolvedModel } from "./types.js";
|
||||
|
||||
/** Resolves scene → provider endpoint + model using {@link WorkflowConfig.providers} and {@link WorkflowConfig.models}. */
|
||||
export function resolveModel(config: WorkflowConfig, scene: string): Result<ResolvedModel, string> {
|
||||
const models = config.models;
|
||||
let ref = models[scene] ?? null;
|
||||
if (ref === null) {
|
||||
ref = models.default ?? null;
|
||||
}
|
||||
if (ref === null) {
|
||||
return err(`no model mapping for scene "${scene}" and no models.default fallback`);
|
||||
}
|
||||
const split = splitProviderModelRef(ref);
|
||||
if (!split.ok) {
|
||||
return split;
|
||||
}
|
||||
const { providerName, modelName } = split.value;
|
||||
const provider = config.providers[providerName] ?? null;
|
||||
if (provider === null) {
|
||||
return err(`unknown provider "${providerName}" referenced by scene "${scene}"`);
|
||||
}
|
||||
return ok({
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
model: modelName,
|
||||
});
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
/** Parses `providerName/modelName` references used in {@link WorkflowConfig.models}. */
|
||||
export function splitProviderModelRef(
|
||||
ref: string,
|
||||
): Result<{ providerName: string; modelName: string }, string> {
|
||||
const idx = ref.indexOf("/");
|
||||
if (idx <= 0 || idx === ref.length - 1) {
|
||||
return err(`invalid model reference "${ref}": expected providerName/modelName`);
|
||||
}
|
||||
const providerName = ref.slice(0, idx);
|
||||
const modelName = ref.slice(idx + 1);
|
||||
if (providerName === "" || modelName === "") {
|
||||
return err(`invalid model reference "${ref}": expected providerName/modelName`);
|
||||
}
|
||||
return ok({ providerName, modelName });
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
export type ResolvedModel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* Re-export of {@link createWorkflow} from `@uncaged/workflow-runtime`.
|
||||
*
|
||||
* The runtime's `createWorkflow` already binds role definitions + agents to a workflow loop
|
||||
* and delegates structured meta extraction to `WorkflowRuntime.extract`, which the engine
|
||||
* supplies (resolved from the `extract` scene in workflow.yaml).
|
||||
*/
|
||||
export { createWorkflow } from "@uncaged/workflow-runtime";
|
||||
@@ -1,415 +0,0 @@
|
||||
import { appendFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import type {
|
||||
LlmProvider,
|
||||
RoleOutput,
|
||||
ThreadContext,
|
||||
WorkflowCompletion,
|
||||
WorkflowFn,
|
||||
WorkflowResult,
|
||||
WorkflowRuntime,
|
||||
} from "@uncaged/workflow-runtime";
|
||||
import { START } from "@uncaged/workflow-runtime";
|
||||
import {
|
||||
type CasStore,
|
||||
getContentMerklePayload,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
} from "../cas/index.js";
|
||||
import { resolveModel } from "../config/index.js";
|
||||
import { createExtract } from "../extract/index.js";
|
||||
import { readWorkflowRegistry, type WorkflowConfig } from "../registry/index.js";
|
||||
import { err, type LogFn, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||
|
||||
import { runSupervisor } from "./supervisor.js";
|
||||
import type { ExecuteThreadIo, ExecuteThreadOptions } from "./types.js";
|
||||
|
||||
async function resolveEngineRegistryRuntime(
|
||||
storageRoot: string,
|
||||
cas: CasStore,
|
||||
): Promise<
|
||||
Result<
|
||||
{
|
||||
extract: ReturnType<typeof createExtract>;
|
||||
workflowConfig: WorkflowConfig;
|
||||
},
|
||||
string
|
||||
>
|
||||
> {
|
||||
const reg = await readWorkflowRegistry(storageRoot);
|
||||
if (!reg.ok) {
|
||||
return err(reg.error.message);
|
||||
}
|
||||
const cfg = reg.value.config;
|
||||
if (cfg === null) {
|
||||
return err("workflow registry has no global config section");
|
||||
}
|
||||
const resolved = resolveModel(cfg, "extract");
|
||||
if (!resolved.ok) {
|
||||
return resolved;
|
||||
}
|
||||
const ex = resolved.value;
|
||||
const llmProvider: LlmProvider = {
|
||||
baseUrl: ex.baseUrl,
|
||||
apiKey: ex.apiKey,
|
||||
model: ex.model,
|
||||
};
|
||||
return ok({ extract: createExtract(llmProvider, { cas }), workflowConfig: cfg });
|
||||
}
|
||||
|
||||
async function appendDataLine(path: string, record: unknown): Promise<void> {
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
await appendFile(path, line, "utf8");
|
||||
}
|
||||
|
||||
async function finalizeThreadResult(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: readonly string[];
|
||||
completion: WorkflowCompletion;
|
||||
}): Promise<WorkflowResult> {
|
||||
const rootHash = await putThreadMerkleNode(
|
||||
params.cas,
|
||||
{
|
||||
workflow: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
result: {
|
||||
returnCode: params.completion.returnCode,
|
||||
summary: params.completion.summary,
|
||||
},
|
||||
},
|
||||
params.stepMerkleHashes,
|
||||
);
|
||||
return {
|
||||
returnCode: params.completion.returnCode,
|
||||
summary: params.completion.summary,
|
||||
rootHash,
|
||||
};
|
||||
}
|
||||
|
||||
async function finalizeAbortedThread(params: {
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
threadId: string;
|
||||
stepMerkleHashes: string[];
|
||||
logger: LogFn;
|
||||
abortLogTag: string;
|
||||
}): Promise<WorkflowResult> {
|
||||
params.logger(params.abortLogTag, `thread ${params.threadId} aborted`);
|
||||
return finalizeThreadResult({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
completion: { returnCode: 130, summary: "thread aborted" },
|
||||
});
|
||||
}
|
||||
|
||||
async function maybeSupervisorHaltsThread(params: {
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
written: number;
|
||||
recentSupervisorSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
threadId: string;
|
||||
cas: CasStore;
|
||||
workflowName: string;
|
||||
stepMerkleHashes: string[];
|
||||
}): Promise<WorkflowResult | null> {
|
||||
const interval = params.workflowConfig.supervisorInterval;
|
||||
if (interval <= 0 || params.written % interval !== 0) {
|
||||
return null;
|
||||
}
|
||||
const sup = await runSupervisor({
|
||||
config: params.workflowConfig,
|
||||
prompt: params.thread.start.content,
|
||||
recentSteps: params.recentSupervisorSteps,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!sup.ok) {
|
||||
params.logger("K6PW9NYT", `supervisor skipped: ${sup.error}`);
|
||||
return null;
|
||||
}
|
||||
if (sup.value !== "stop") {
|
||||
return null;
|
||||
}
|
||||
params.logger("M4QX8VHN", `thread ${params.threadId} stopped by supervisor`);
|
||||
return finalizeThreadResult({
|
||||
cas: params.cas,
|
||||
workflowName: params.workflowName,
|
||||
threadId: params.threadId,
|
||||
stepMerkleHashes: params.stepMerkleHashes,
|
||||
completion: { returnCode: 0, summary: "completed: supervisor stopped thread" },
|
||||
});
|
||||
}
|
||||
|
||||
async function driveWorkflowGenerator(params: {
|
||||
fn: WorkflowFn;
|
||||
workflowName: string;
|
||||
workflowConfig: WorkflowConfig;
|
||||
thread: ThreadContext;
|
||||
runtime: WorkflowRuntime;
|
||||
executeOptions: ExecuteThreadOptions;
|
||||
dataJsonlPath: string;
|
||||
threadId: string;
|
||||
logger: LogFn;
|
||||
cas: CasStore;
|
||||
stepMerkleHashes: string[];
|
||||
}): Promise<WorkflowResult> {
|
||||
const {
|
||||
fn,
|
||||
workflowName,
|
||||
workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions,
|
||||
dataJsonlPath,
|
||||
threadId,
|
||||
logger,
|
||||
cas,
|
||||
stepMerkleHashes,
|
||||
} = params;
|
||||
const gen = fn(thread, runtime);
|
||||
let written = 0;
|
||||
const recentSupervisorSteps: { role: string; summary: string }[] = thread.steps.map((s) => ({
|
||||
role: s.role,
|
||||
summary: JSON.stringify(s.meta),
|
||||
}));
|
||||
|
||||
while (true) {
|
||||
if (executeOptions.signal.aborted) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP2",
|
||||
});
|
||||
}
|
||||
|
||||
if (written >= executeOptions.maxRounds) {
|
||||
logger("R3CW7YBQ", `thread ${threadId} stopped at maxRounds=${executeOptions.maxRounds}`);
|
||||
return await finalizeThreadResult({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${executeOptions.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const iterResult = await gen.next();
|
||||
|
||||
if (iterResult.done) {
|
||||
logger("F3HN8QKP", `thread ${threadId} generator finished`);
|
||||
const completion = iterResult.value;
|
||||
return await finalizeThreadResult({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
written++;
|
||||
const step = iterResult.value;
|
||||
const resolved = await getContentMerklePayload(cas, step.contentHash);
|
||||
if (resolved === null) {
|
||||
throw new Error(
|
||||
`role step ${step.role}: CAS blob missing for contentHash ${step.contentHash}`,
|
||||
);
|
||||
}
|
||||
const ts = Date.now();
|
||||
await appendDataLine(dataJsonlPath, {
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: normalizeRefsField(step.refs),
|
||||
timestamp: ts,
|
||||
});
|
||||
|
||||
const stepNodeHash = await putStepMerkleNode(
|
||||
cas,
|
||||
{ role: step.role, meta: step.meta },
|
||||
step.contentHash,
|
||||
);
|
||||
stepMerkleHashes.push(stepNodeHash);
|
||||
|
||||
logger("N7BW4YHQ", `thread ${threadId} wrote role ${step.role}`);
|
||||
|
||||
recentSupervisorSteps.push({
|
||||
role: step.role,
|
||||
summary: JSON.stringify(step.meta),
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
executeOptions.awaitAfterEachYield(),
|
||||
new Promise<void>((resolve) => {
|
||||
if (executeOptions.signal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
executeOptions.signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
]);
|
||||
|
||||
if (executeOptions.signal.aborted) {
|
||||
return await finalizeAbortedThread({
|
||||
cas,
|
||||
workflowName,
|
||||
threadId,
|
||||
stepMerkleHashes,
|
||||
logger,
|
||||
abortLogTag: "V8JX4NP4",
|
||||
});
|
||||
}
|
||||
|
||||
const supervised = await maybeSupervisorHaltsThread({
|
||||
workflowConfig,
|
||||
thread,
|
||||
written,
|
||||
recentSupervisorSteps,
|
||||
logger,
|
||||
threadId,
|
||||
cas,
|
||||
workflowName,
|
||||
stepMerkleHashes,
|
||||
});
|
||||
if (supervised !== null) {
|
||||
return supervised;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a workflow thread: drive the bundle's AsyncGenerator, RFC-001 `.data.jsonl` records,
|
||||
* debug lines via `logger` to `.info.jsonl`.
|
||||
*/
|
||||
export async function executeThread(
|
||||
fn: WorkflowFn,
|
||||
workflowName: string,
|
||||
input: { prompt: string; steps: RoleOutput[] },
|
||||
options: ExecuteThreadOptions,
|
||||
io: ExecuteThreadIo,
|
||||
logger: LogFn,
|
||||
): Promise<WorkflowResult> {
|
||||
await mkdir(dirname(io.dataJsonlPath), { recursive: true });
|
||||
await mkdir(dirname(io.infoJsonlPath), { recursive: true });
|
||||
|
||||
const prefilled = options.prefilledDiskSteps;
|
||||
if (prefilled !== null && prefilled.length !== input.steps.length) {
|
||||
throw new Error(
|
||||
`prefilledDiskSteps length (${prefilled.length}) must match input.steps length (${input.steps.length})`,
|
||||
);
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const startRecord: Record<string, unknown> = {
|
||||
name: workflowName,
|
||||
hash: io.hash,
|
||||
threadId: io.threadId,
|
||||
parameters: {
|
||||
prompt: input.prompt,
|
||||
options: {
|
||||
maxRounds: options.maxRounds,
|
||||
depth: options.depth,
|
||||
},
|
||||
},
|
||||
timestamp: nowMs,
|
||||
};
|
||||
if (options.forkSourceThreadId !== null) {
|
||||
startRecord.forkFrom = { threadId: options.forkSourceThreadId };
|
||||
}
|
||||
|
||||
await appendDataLine(io.dataJsonlPath, startRecord);
|
||||
|
||||
logger("T9HQ2KHM", `thread ${io.threadId} started for workflow ${workflowName}`);
|
||||
|
||||
const stepMerkleHashes: string[] = [];
|
||||
|
||||
if (prefilled !== null) {
|
||||
for (const row of prefilled) {
|
||||
const prefilledPayload = await getContentMerklePayload(io.cas, row.contentHash);
|
||||
if (prefilledPayload === null) {
|
||||
throw new Error(
|
||||
`prefilled step ${row.role}: CAS blob missing for contentHash ${row.contentHash}`,
|
||||
);
|
||||
}
|
||||
await appendDataLine(io.dataJsonlPath, {
|
||||
role: row.role,
|
||||
contentHash: row.contentHash,
|
||||
meta: row.meta,
|
||||
refs: normalizeRefsField(row.refs),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
const stepNodeHash = await putStepMerkleNode(
|
||||
io.cas,
|
||||
{ role: row.role, meta: row.meta },
|
||||
row.contentHash,
|
||||
);
|
||||
stepMerkleHashes.push(stepNodeHash);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.maxRounds <= 0) {
|
||||
logger("R3CW7YBQ", `thread ${io.threadId} stopped at maxRounds=${options.maxRounds}`);
|
||||
return await finalizeThreadResult({
|
||||
cas: io.cas,
|
||||
workflowName,
|
||||
threadId: io.threadId,
|
||||
stepMerkleHashes,
|
||||
completion: {
|
||||
returnCode: 0,
|
||||
summary: `completed: reached maxRounds (${options.maxRounds})`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const registryRuntime = await resolveEngineRegistryRuntime(options.storageRoot, io.cas);
|
||||
if (!registryRuntime.ok) {
|
||||
throw new Error(registryRuntime.error);
|
||||
}
|
||||
|
||||
const thread: ThreadContext = {
|
||||
threadId: io.threadId,
|
||||
depth: options.depth,
|
||||
start: {
|
||||
role: START,
|
||||
content: input.prompt,
|
||||
meta: { maxRounds: options.maxRounds },
|
||||
timestamp: nowMs,
|
||||
},
|
||||
steps: input.steps.map((out, i) => ({
|
||||
role: out.role,
|
||||
contentHash: out.contentHash,
|
||||
meta: out.meta,
|
||||
refs: out.refs,
|
||||
timestamp: prefilled?.[i]?.timestamp ?? nowMs + i,
|
||||
})),
|
||||
};
|
||||
|
||||
const runtime: WorkflowRuntime = {
|
||||
cas: io.cas,
|
||||
extract: registryRuntime.value.extract,
|
||||
};
|
||||
|
||||
return await driveWorkflowGenerator({
|
||||
fn,
|
||||
workflowName,
|
||||
workflowConfig: registryRuntime.value.workflowConfig,
|
||||
thread,
|
||||
runtime,
|
||||
executeOptions: options,
|
||||
dataJsonlPath: io.dataJsonlPath,
|
||||
threadId: io.threadId,
|
||||
logger,
|
||||
cas: io.cas,
|
||||
stepMerkleHashes,
|
||||
});
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
import type { WorkflowCompletion } from "@uncaged/workflow-runtime";
|
||||
import { err, normalizeRefsField, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { ForkHistoricalStep, ForkPlan, ParsedThreadStartRecord } from "./types.js";
|
||||
|
||||
/** Recognizes a persisted workflow completion line (no `role`; has numeric `returnCode` and string `summary`). Omits `rootHash` when absent. */
|
||||
export function tryParseWorkflowResultRecord(
|
||||
obj: Record<string, unknown>,
|
||||
): WorkflowCompletion | null {
|
||||
if (obj.role !== undefined) {
|
||||
return null;
|
||||
}
|
||||
const returnCode = obj.returnCode;
|
||||
const summary = obj.summary;
|
||||
if (typeof returnCode !== "number" || typeof summary !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { returnCode, summary };
|
||||
}
|
||||
|
||||
export function tryParseRoleStepRecord(obj: Record<string, unknown>): ForkHistoricalStep | null {
|
||||
const role = obj.role;
|
||||
const contentHash = obj.contentHash;
|
||||
const meta = obj.meta;
|
||||
const timestamp = obj.timestamp;
|
||||
if (typeof role !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (typeof contentHash !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (meta === null || typeof meta !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (typeof timestamp !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
role,
|
||||
contentHash,
|
||||
meta: meta as Record<string, unknown>,
|
||||
refs: normalizeRefsField(obj.refs),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRoleLine(
|
||||
obj: Record<string, unknown>,
|
||||
lineIndex: number,
|
||||
): Result<ForkHistoricalStep, string> {
|
||||
const parsed = tryParseRoleStepRecord(obj);
|
||||
if (parsed === null) {
|
||||
return err(`invalid role record at line ${lineIndex}`);
|
||||
}
|
||||
return ok(parsed);
|
||||
}
|
||||
|
||||
function parseStartRecordLine(firstLine: string): Result<ParsedThreadStartRecord, string> {
|
||||
let startParsed: unknown;
|
||||
try {
|
||||
startParsed = JSON.parse(firstLine) as unknown;
|
||||
} catch {
|
||||
return err("invalid JSON on line 1 (start record)");
|
||||
}
|
||||
if (startParsed === null || typeof startParsed !== "object") {
|
||||
return err("invalid start record shape");
|
||||
}
|
||||
const startRec = startParsed as Record<string, unknown>;
|
||||
const name = startRec.name;
|
||||
const hash = startRec.hash;
|
||||
const threadId = startRec.threadId;
|
||||
const parameters = startRec.parameters;
|
||||
if (typeof name !== "string" || typeof hash !== "string" || typeof threadId !== "string") {
|
||||
return err("start record missing name, hash, or threadId");
|
||||
}
|
||||
if (parameters === null || typeof parameters !== "object") {
|
||||
return err("start record missing parameters");
|
||||
}
|
||||
const paramsRec = parameters as Record<string, unknown>;
|
||||
const prompt = paramsRec.prompt;
|
||||
const options = paramsRec.options;
|
||||
if (typeof prompt !== "string") {
|
||||
return err("start record missing parameters.prompt");
|
||||
}
|
||||
if (options === null || typeof options !== "object") {
|
||||
return err("start record missing parameters.options");
|
||||
}
|
||||
const optRec = options as Record<string, unknown>;
|
||||
const maxRounds = optRec.maxRounds;
|
||||
if (typeof maxRounds !== "number") {
|
||||
return err("start record missing parameters.options.maxRounds");
|
||||
}
|
||||
|
||||
const depthRaw = optRec.depth;
|
||||
const depth =
|
||||
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
|
||||
|
||||
return ok({
|
||||
workflowName: name,
|
||||
hash,
|
||||
threadId,
|
||||
prompt,
|
||||
maxRounds,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
|
||||
function parseFollowingRoleLines(lines: string[]): Result<ForkHistoricalStep[], string> {
|
||||
const roleSteps: ForkHistoricalStep[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line === undefined) {
|
||||
break;
|
||||
}
|
||||
let rec: unknown;
|
||||
try {
|
||||
rec = JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return err(`invalid JSON at line ${i + 1}`);
|
||||
}
|
||||
if (rec === null || typeof rec !== "object") {
|
||||
return err(`invalid record at line ${i + 1}`);
|
||||
}
|
||||
const recObj = rec as Record<string, unknown>;
|
||||
const wf = tryParseWorkflowResultRecord(recObj);
|
||||
if (wf !== null) {
|
||||
if (i !== lines.length - 1) {
|
||||
return err("WorkflowResult record must be the final line in `.data.jsonl`");
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parsed = parseRoleLine(recObj, i + 1);
|
||||
if (!parsed.ok) {
|
||||
return parsed;
|
||||
}
|
||||
roleSteps.push(parsed.value);
|
||||
}
|
||||
return ok(roleSteps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RFC-001 `.data.jsonl`: line 1 start record, line 2+ role outputs.
|
||||
*/
|
||||
export function parseThreadDataJsonl(text: string): Result<
|
||||
{
|
||||
start: ParsedThreadStartRecord;
|
||||
roleSteps: ForkHistoricalStep[];
|
||||
},
|
||||
string
|
||||
> {
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l !== "");
|
||||
if (lines.length === 0) {
|
||||
return err("thread data is empty");
|
||||
}
|
||||
|
||||
const firstLine = lines[0];
|
||||
if (firstLine === undefined) {
|
||||
return err("thread data is empty");
|
||||
}
|
||||
|
||||
const start = parseStartRecordLine(firstLine);
|
||||
if (!start.ok) {
|
||||
return start;
|
||||
}
|
||||
|
||||
const roleSteps = parseFollowingRoleLines(lines);
|
||||
if (!roleSteps.ok) {
|
||||
return roleSteps;
|
||||
}
|
||||
|
||||
return ok({
|
||||
start: start.value,
|
||||
roleSteps: roleSteps.value,
|
||||
});
|
||||
}
|
||||
|
||||
function orderedUniqueRoles(roleSteps: ForkHistoricalStep[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const s of roleSteps) {
|
||||
if (!seen.has(s.role)) {
|
||||
seen.add(s.role);
|
||||
out.push(s.role);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select historical steps for a fork:
|
||||
* - `fromRole === null`: drop the last step (retry the last role).
|
||||
* - `fromRole !== null`: keep steps through the first occurrence of that role (inclusive).
|
||||
*/
|
||||
export function selectForkHistoricalSteps(
|
||||
roleSteps: ForkHistoricalStep[],
|
||||
fromRole: string | null,
|
||||
): Result<ForkHistoricalStep[], string> {
|
||||
if (roleSteps.length === 0) {
|
||||
return err("thread has no completed role steps to fork from");
|
||||
}
|
||||
|
||||
if (fromRole === null) {
|
||||
if (roleSteps.length === 1) {
|
||||
return ok([]);
|
||||
}
|
||||
return ok(roleSteps.slice(0, -1));
|
||||
}
|
||||
|
||||
const idx = roleSteps.findIndex((s) => s.role === fromRole);
|
||||
if (idx < 0) {
|
||||
const available = orderedUniqueRoles(roleSteps);
|
||||
return err(`role not found in thread: ${fromRole} (available: ${available.join(", ")})`);
|
||||
}
|
||||
return ok(roleSteps.slice(0, idx + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `.data.jsonl` text and compute fork payload for the worker `run` command.
|
||||
*/
|
||||
export function buildForkPlan(
|
||||
dataJsonlText: string,
|
||||
fromRole: string | null,
|
||||
): Result<ForkPlan, string> {
|
||||
const parsed = parseThreadDataJsonl(dataJsonlText);
|
||||
if (!parsed.ok) {
|
||||
return parsed;
|
||||
}
|
||||
const selected = selectForkHistoricalSteps(parsed.value.roleSteps, fromRole);
|
||||
if (!selected.ok) {
|
||||
return selected;
|
||||
}
|
||||
const { start } = parsed.value;
|
||||
return ok({
|
||||
workflowName: start.workflowName,
|
||||
hash: start.hash,
|
||||
sourceThreadId: start.threadId,
|
||||
prompt: start.prompt,
|
||||
runOptions: { maxRounds: start.maxRounds, depth: start.depth },
|
||||
historicalSteps: selected.value,
|
||||
});
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { type CasStore, createCasStore } from "../cas/index.js";
|
||||
import { err, getGlobalCasDir, ok, type Result } from "../util/index.js";
|
||||
import { parseThreadDataJsonl } from "./fork-thread.js";
|
||||
import type { GcResult } from "./types.js";
|
||||
|
||||
async function listThreadDataJsonlPaths(storageRoot: string): Promise<Result<string[], string>> {
|
||||
const logsRoot = join(storageRoot, "logs");
|
||||
const paths: string[] = [];
|
||||
let hashes: string[];
|
||||
try {
|
||||
hashes = await readdir(logsRoot);
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return ok([]);
|
||||
}
|
||||
return err(`failed to read logs directory: ${String(e)}`);
|
||||
}
|
||||
|
||||
for (const hash of hashes) {
|
||||
const dir = join(logsRoot, hash);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const fileName of entries) {
|
||||
if (fileName.endsWith(".data.jsonl")) {
|
||||
paths.push(join(dir, fileName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths.sort();
|
||||
return ok(paths);
|
||||
}
|
||||
|
||||
async function collectActiveRefsFromDataPaths(
|
||||
dataPaths: string[],
|
||||
): Promise<Result<Set<string>, string>> {
|
||||
const activeRefs = new Set<string>();
|
||||
for (const dataPath of dataPaths) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(dataPath, "utf8");
|
||||
} catch (e) {
|
||||
return err(`failed to read ${dataPath}: ${String(e)}`);
|
||||
}
|
||||
const parsed = parseThreadDataJsonl(text);
|
||||
if (!parsed.ok) {
|
||||
return err(`${dataPath}: ${parsed.error}`);
|
||||
}
|
||||
for (const step of parsed.value.roleSteps) {
|
||||
for (const ref of step.refs) {
|
||||
activeRefs.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ok(activeRefs);
|
||||
}
|
||||
|
||||
async function deleteCasNotInSet(
|
||||
cas: CasStore,
|
||||
activeRefs: Set<string>,
|
||||
): Promise<Result<string[], string>> {
|
||||
let listed: string[];
|
||||
try {
|
||||
listed = await cas.list();
|
||||
} catch (e) {
|
||||
return err(`failed to list cas entries: ${String(e)}`);
|
||||
}
|
||||
|
||||
const deletedHashes: string[] = [];
|
||||
for (const hash of listed) {
|
||||
if (activeRefs.has(hash)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await cas.delete(hash);
|
||||
} catch (e) {
|
||||
return err(`failed to delete cas ${hash}: ${String(e)}`);
|
||||
}
|
||||
deletedHashes.push(hash);
|
||||
}
|
||||
|
||||
deletedHashes.sort();
|
||||
return ok(deletedHashes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark-and-sweep CAS GC: collect `refs` from all thread `.data.jsonl` files under `storageRoot`,
|
||||
* then delete CAS blobs not referenced by any surviving thread data.
|
||||
*/
|
||||
export async function garbageCollectCas(storageRoot: string): Promise<Result<GcResult, string>> {
|
||||
const pathsResult = await listThreadDataJsonlPaths(storageRoot);
|
||||
if (!pathsResult.ok) {
|
||||
return pathsResult;
|
||||
}
|
||||
const paths = pathsResult.value;
|
||||
|
||||
const refsResult = await collectActiveRefsFromDataPaths(paths);
|
||||
if (!refsResult.ok) {
|
||||
return refsResult;
|
||||
}
|
||||
const activeRefs = refsResult.value;
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
const deletedResult = await deleteCasNotInSet(cas, activeRefs);
|
||||
if (!deletedResult.ok) {
|
||||
return deletedResult;
|
||||
}
|
||||
const deletedHashes = deletedResult.value;
|
||||
|
||||
return ok({
|
||||
scannedThreads: paths.length,
|
||||
activeRefs: activeRefs.size,
|
||||
deletedEntries: deletedHashes.length,
|
||||
deletedHashes,
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export { createWorkflow } from "./create-workflow.js";
|
||||
export { executeThread } from "./engine.js";
|
||||
export {
|
||||
buildForkPlan,
|
||||
parseThreadDataJsonl,
|
||||
selectForkHistoricalSteps,
|
||||
tryParseRoleStepRecord,
|
||||
tryParseWorkflowResultRecord,
|
||||
} from "./fork-thread.js";
|
||||
export { garbageCollectCas } from "./gc.js";
|
||||
export { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
export type {
|
||||
ExecuteThreadIo,
|
||||
ExecuteThreadOptions,
|
||||
ForkHistoricalStep,
|
||||
ForkPlan,
|
||||
GcResult,
|
||||
ParsedThreadStartRecord,
|
||||
PrefilledDiskStep,
|
||||
SupervisorDecision,
|
||||
ThreadPauseGate,
|
||||
} from "./types.js";
|
||||
export { getWorkerHostScriptPath } from "./worker-entry-path.js";
|
||||
@@ -1,85 +0,0 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { resolveModel } from "../config/index.js";
|
||||
import { extractFunctionToolFromZodSchema } from "../extract/index.js";
|
||||
import { createLlmFn, createThreadReactor } from "../reactor/index.js";
|
||||
import type { WorkflowConfig } from "../registry/index.js";
|
||||
import { err, type LogFn, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { SupervisorDecision } from "./types.js";
|
||||
|
||||
const SUPERVISOR_RECENT_STEP_LIMIT = 12;
|
||||
const SUPERVISOR_MAX_REACT_ROUNDS = 4;
|
||||
|
||||
const supervisorDecisionSchema = z
|
||||
.object({
|
||||
decision: z.enum(["continue", "stop"]),
|
||||
})
|
||||
.meta({
|
||||
title: "supervisor_decision",
|
||||
description:
|
||||
'Workflow supervisor decision. "continue" when the thread is making progress; "stop" when done, looping, or stuck.',
|
||||
});
|
||||
|
||||
type SupervisorThreadContext = Record<string, never>;
|
||||
|
||||
type RunSupervisorArgs = {
|
||||
config: WorkflowConfig;
|
||||
prompt: string;
|
||||
recentSteps: readonly { role: string; summary: string }[];
|
||||
logger: LogFn;
|
||||
};
|
||||
|
||||
function buildSupervisorInput(args: RunSupervisorArgs): string {
|
||||
const recent = args.recentSteps.slice(-SUPERVISOR_RECENT_STEP_LIMIT);
|
||||
const stepsBlock = recent.map((s, index) => `${index + 1}. [${s.role}] ${s.summary}`).join("\n");
|
||||
return `Original task:\n${args.prompt}\n\nRecent steps (oldest first):\n${stepsBlock === "" ? "(none)" : stepsBlock}`;
|
||||
}
|
||||
|
||||
/** Calls the `supervisor` scene via {@link createThreadReactor}; opt-out when {@link resolveModel} fails (returns ok(`continue`)). */
|
||||
export async function runSupervisor(
|
||||
args: RunSupervisorArgs,
|
||||
): Promise<Result<SupervisorDecision, string>> {
|
||||
const resolved = resolveModel(args.config, "supervisor");
|
||||
if (!resolved.ok) {
|
||||
return ok("continue");
|
||||
}
|
||||
|
||||
const reactor = createThreadReactor<SupervisorThreadContext>({
|
||||
llm: createLlmFn(resolved.value),
|
||||
maxRounds: SUPERVISOR_MAX_REACT_ROUNDS,
|
||||
staticTools: [],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You supervise a multi-step workflow. Decide whether the thread should keep running or halt. Reply with "continue" when the thread is making progress toward the task, or "stop" when it is finished, looping, or no longer making progress. Call the ${structuredToolName} tool with JSON arguments matching the schema, or reply with only a JSON object such as {"decision":"stop"}.`,
|
||||
toolHandler: async (call) => `Unknown tool: ${call.function.name}`,
|
||||
});
|
||||
|
||||
const result = await reactor({
|
||||
thread: {} as SupervisorThreadContext,
|
||||
input: buildSupervisorInput(args),
|
||||
schema: supervisorDecisionSchema,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
args.logger("R9CW4PLM", `supervisor failed: ${result.error}`);
|
||||
return err(`supervisor: ${result.error}`);
|
||||
}
|
||||
|
||||
const decision: SupervisorDecision = result.value.decision;
|
||||
args.logger("Z8KM5QWT", `supervisor says ${decision}`);
|
||||
return ok(decision);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { ThreadPauseGate } from "./types.js";
|
||||
|
||||
/**
|
||||
* Pause/resume gate for workflow threads: after each generator yield the engine awaits
|
||||
* {@link ThreadPauseGate.awaitAfterYield}. Calling {@link ThreadPauseGate.pause} makes the next
|
||||
* await block until {@link ThreadPauseGate.resume}.
|
||||
*/
|
||||
export function createThreadPauseGate(): ThreadPauseGate {
|
||||
let resumeResolver: (() => void) | null = null;
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
let paused = false;
|
||||
|
||||
function awaitAfterYield(): Promise<void> {
|
||||
return chain;
|
||||
}
|
||||
|
||||
function pause(): Result<void, string> {
|
||||
if (paused) {
|
||||
return err("thread already paused");
|
||||
}
|
||||
paused = true;
|
||||
chain = new Promise<void>((resolve) => {
|
||||
resumeResolver = resolve;
|
||||
});
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function resume(): Result<void, string> {
|
||||
if (!paused) {
|
||||
return err("thread not paused");
|
||||
}
|
||||
paused = false;
|
||||
const resolveFn = resumeResolver;
|
||||
resumeResolver = null;
|
||||
if (resolveFn !== null) {
|
||||
resolveFn();
|
||||
}
|
||||
chain = Promise.resolve();
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
function isPaused(): boolean {
|
||||
return paused;
|
||||
}
|
||||
|
||||
return { awaitAfterYield, pause, resume, isPaused };
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { RoleOutput } from "@uncaged/workflow-runtime";
|
||||
import type { CasStore } from "../cas/index.js";
|
||||
import type { Result } from "../util/index.js";
|
||||
|
||||
export type SupervisorDecision = "continue" | "stop";
|
||||
|
||||
export type ExecuteThreadIo = {
|
||||
threadId: string;
|
||||
hash: string;
|
||||
dataJsonlPath: string;
|
||||
infoJsonlPath: string;
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
/** One persisted role line in `.data.jsonl` (engine adds these for fork replay before running the generator). */
|
||||
export type PrefilledDiskStep = {
|
||||
role: string;
|
||||
contentHash: string;
|
||||
meta: Record<string, unknown>;
|
||||
refs: string[];
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type ExecuteThreadOptions = {
|
||||
maxRounds: number;
|
||||
/** Passed to the bundle thread context as `ThreadContext.depth`. */
|
||||
depth: number;
|
||||
signal: AbortSignal;
|
||||
/** Invoked after each successful yield (and outer-loop checks); used for pause/resume. */
|
||||
awaitAfterEachYield: () => Promise<void>;
|
||||
/** When non-null, written into the start record so tooling can trace lineage. */
|
||||
forkSourceThreadId: string | null;
|
||||
/**
|
||||
* Written to `.data.jsonl` immediately after the start record, before the generator runs.
|
||||
* Must match `input.steps` length and order when present.
|
||||
*/
|
||||
prefilledDiskSteps: PrefilledDiskStep[] | null;
|
||||
/** Workspace root containing `workflow.yaml`; used to resolve the `extract` scene for meta extraction. */
|
||||
storageRoot: string;
|
||||
};
|
||||
|
||||
/** Role steps replayed from `.data.jsonl`, including persisted timestamps. */
|
||||
export type ForkHistoricalStep = RoleOutput & { timestamp: number };
|
||||
|
||||
export type ParsedThreadStartRecord = {
|
||||
workflowName: string;
|
||||
hash: string;
|
||||
threadId: string;
|
||||
prompt: string;
|
||||
maxRounds: number;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type ForkPlan = {
|
||||
workflowName: string;
|
||||
hash: string;
|
||||
sourceThreadId: string;
|
||||
prompt: string;
|
||||
runOptions: { maxRounds: number; depth: number };
|
||||
historicalSteps: ForkHistoricalStep[];
|
||||
};
|
||||
|
||||
export type GcResult = {
|
||||
scannedThreads: number;
|
||||
activeRefs: number;
|
||||
deletedEntries: number;
|
||||
deletedHashes: string[];
|
||||
};
|
||||
|
||||
export type ThreadPauseGate = {
|
||||
awaitAfterYield: () => Promise<void>;
|
||||
pause: () => Result<void, string>;
|
||||
resume: () => Result<void, string>;
|
||||
isPaused: () => boolean;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Absolute path to `worker-host.ts` for spawning bundle worker processes. */
|
||||
export function getWorkerHostScriptPath(): string {
|
||||
return fileURLToPath(new URL("./worker.ts", import.meta.url));
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import { appendFile, mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { RoleOutput, WorkflowFn, WorkflowResult } from "@uncaged/workflow-runtime";
|
||||
import { ensureUncagedWorkflowSymlink, importWorkflowBundleModule } from "../bundle/index.js";
|
||||
import { createCasStore } from "../cas/index.js";
|
||||
import {
|
||||
createLogger,
|
||||
err,
|
||||
getGlobalCasDir,
|
||||
normalizeRefsField,
|
||||
ok,
|
||||
type Result,
|
||||
} from "../util/index.js";
|
||||
import { executeThread } from "./engine.js";
|
||||
import { createThreadPauseGate } from "./thread-pause-gate.js";
|
||||
import type { ExecuteThreadIo, PrefilledDiskStep, ThreadPauseGate } from "./types.js";
|
||||
|
||||
const bootLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
type RunCommand = {
|
||||
type: "run";
|
||||
threadId: string;
|
||||
workflowName: string;
|
||||
prompt: string;
|
||||
options: { maxRounds: number; depth: number };
|
||||
steps: RoleOutput[];
|
||||
/** Timestamps aligned with `steps` for `.data.jsonl` replay; length must match `steps` when non-null. */
|
||||
stepTimestamps: number[] | null;
|
||||
forkSourceThreadId: string | null;
|
||||
};
|
||||
|
||||
type KillCommand = {
|
||||
type: "kill";
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type PauseCommand = {
|
||||
type: "pause";
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type ResumeCommand = {
|
||||
type: "resume";
|
||||
threadId: string;
|
||||
};
|
||||
|
||||
type ControlCommand = RunCommand | KillCommand | PauseCommand | ResumeCommand;
|
||||
|
||||
type ThreadHandle = {
|
||||
abortController: AbortController;
|
||||
pauseGate: ThreadPauseGate;
|
||||
};
|
||||
|
||||
function parseRoleOutputRecord(obj: Record<string, unknown>): RoleOutput | null {
|
||||
const role = obj.role;
|
||||
const contentHash = obj.contentHash;
|
||||
const meta = obj.meta;
|
||||
if (typeof role !== "string" || typeof contentHash !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (meta === null || typeof meta !== "object") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
role,
|
||||
contentHash,
|
||||
meta: meta as Record<string, unknown>,
|
||||
refs: normalizeRefsField(obj.refs),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRunStepsPayload(rec: Record<string, unknown>): {
|
||||
steps: RoleOutput[];
|
||||
stepTimestamps: number[] | null;
|
||||
} | null {
|
||||
const raw = rec.steps;
|
||||
if (raw === undefined || raw === null) {
|
||||
return { steps: [], stepTimestamps: null };
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const steps: RoleOutput[] = [];
|
||||
const timestamps: number[] = [];
|
||||
let anyTimestamp = false;
|
||||
for (const item of raw) {
|
||||
if (item === null || typeof item !== "object") {
|
||||
return null;
|
||||
}
|
||||
const o = item as Record<string, unknown>;
|
||||
const out = parseRoleOutputRecord(o);
|
||||
if (out === null) {
|
||||
return null;
|
||||
}
|
||||
steps.push(out);
|
||||
const ts = o.timestamp;
|
||||
if (ts === undefined) {
|
||||
timestamps.push(0);
|
||||
} else if (typeof ts === "number") {
|
||||
timestamps.push(ts);
|
||||
anyTimestamp = true;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
steps,
|
||||
stepTimestamps: anyTimestamp ? timestamps : null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRunControlPayload(rec: Record<string, unknown>): RunCommand | null {
|
||||
const threadId = rec.threadId;
|
||||
const workflowName = rec.workflowName;
|
||||
const prompt = rec.prompt;
|
||||
const options = rec.options;
|
||||
if (
|
||||
typeof threadId !== "string" ||
|
||||
typeof workflowName !== "string" ||
|
||||
typeof prompt !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (options === null || typeof options !== "object") {
|
||||
return null;
|
||||
}
|
||||
const optRec = options as Record<string, unknown>;
|
||||
const maxRounds = optRec.maxRounds;
|
||||
if (typeof maxRounds !== "number") {
|
||||
return null;
|
||||
}
|
||||
const depthRaw = optRec.depth;
|
||||
const depth =
|
||||
typeof depthRaw === "number" && Number.isFinite(depthRaw) ? Math.trunc(depthRaw) : 0;
|
||||
const parsedSteps = parseRunStepsPayload(rec);
|
||||
if (parsedSteps === null) {
|
||||
return null;
|
||||
}
|
||||
const rawFork = rec.forkSourceThreadId;
|
||||
let forkSourceThreadId: string | null = null;
|
||||
if (rawFork !== undefined && rawFork !== null) {
|
||||
if (typeof rawFork !== "string" || rawFork === "") {
|
||||
return null;
|
||||
}
|
||||
forkSourceThreadId = rawFork;
|
||||
}
|
||||
return {
|
||||
type: "run",
|
||||
threadId,
|
||||
workflowName,
|
||||
prompt,
|
||||
options: { maxRounds, depth },
|
||||
steps: parsedSteps.steps,
|
||||
stepTimestamps: parsedSteps.stepTimestamps,
|
||||
forkSourceThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLifecycleThreadPayload(
|
||||
rec: Record<string, unknown>,
|
||||
): KillCommand | PauseCommand | ResumeCommand | null {
|
||||
const type = rec.type;
|
||||
const threadId = rec.threadId;
|
||||
if (typeof threadId !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (type === "kill") {
|
||||
return { type: "kill", threadId };
|
||||
}
|
||||
if (type === "pause") {
|
||||
return { type: "pause", threadId };
|
||||
}
|
||||
if (type === "resume") {
|
||||
return { type: "resume", threadId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseControlPayload(payload: unknown): ControlCommand | null {
|
||||
if (payload === null || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const rec = payload as Record<string, unknown>;
|
||||
const lifecycle = parseLifecycleThreadPayload(rec);
|
||||
if (lifecycle !== null) {
|
||||
return lifecycle;
|
||||
}
|
||||
if (rec.type === "run") {
|
||||
return parseRunControlPayload(rec);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCommandLine(line: string): ControlCommand | null {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
bootLog("S8KQ3WJP", "worker received invalid JSON control line");
|
||||
return null;
|
||||
}
|
||||
return parseControlPayload(parsed);
|
||||
}
|
||||
|
||||
function isWorkflowFnLike(value: unknown): value is WorkflowFn {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function writeTcpResponse(socket: Socket | null, result: Result<void, string>): void {
|
||||
if (socket === null) {
|
||||
return;
|
||||
}
|
||||
const body = result.ok ? { ok: true as const } : { ok: false as const, error: result.error };
|
||||
socket.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function dispatchThreadLifecycleCommand(
|
||||
threads: Map<string, ThreadHandle>,
|
||||
socket: Socket | null,
|
||||
cmd: KillCommand | PauseCommand | ResumeCommand,
|
||||
): void {
|
||||
const handle = threads.get(cmd.threadId);
|
||||
if (handle === undefined) {
|
||||
writeTcpResponse(socket, err(`thread not found: ${cmd.threadId}`));
|
||||
return;
|
||||
}
|
||||
switch (cmd.type) {
|
||||
case "kill":
|
||||
handle.abortController.abort();
|
||||
bootLog("P9XK2WNQ", `kill requested for thread ${cmd.threadId}`);
|
||||
writeTcpResponse(socket, ok(undefined));
|
||||
return;
|
||||
case "pause": {
|
||||
const paused = handle.pauseGate.pause();
|
||||
if (!paused.ok) {
|
||||
writeTcpResponse(socket, paused);
|
||||
return;
|
||||
}
|
||||
bootLog("K7WQ2NXP", `pause requested for thread ${cmd.threadId}`);
|
||||
writeTcpResponse(socket, ok(undefined));
|
||||
return;
|
||||
}
|
||||
case "resume": {
|
||||
const resumed = handle.pauseGate.resume();
|
||||
if (!resumed.ok) {
|
||||
writeTcpResponse(socket, resumed);
|
||||
return;
|
||||
}
|
||||
bootLog("M4YT8HKR", `resume requested for thread ${cmd.threadId}`);
|
||||
writeTcpResponse(socket, ok(undefined));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readLineFromSocket(socket: Socket): Promise<string | null> {
|
||||
return await new Promise((resolve) => {
|
||||
let buf = "";
|
||||
function onData(chunk: Buffer): void {
|
||||
buf += chunk.toString("utf8");
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl >= 0) {
|
||||
cleanup();
|
||||
resolve(buf.slice(0, nl));
|
||||
}
|
||||
}
|
||||
function onEnd(): void {
|
||||
cleanup();
|
||||
resolve(buf === "" ? null : buf);
|
||||
}
|
||||
function onError(): void {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
function cleanup(): void {
|
||||
socket.off("data", onData);
|
||||
socket.off("end", onEnd);
|
||||
socket.off("error", onError);
|
||||
}
|
||||
socket.on("data", onData);
|
||||
socket.on("end", onEnd);
|
||||
socket.on("error", onError);
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const bundlePath = process.argv[2];
|
||||
const storageRoot = process.argv[3];
|
||||
const hash = process.argv[4];
|
||||
|
||||
if (
|
||||
bundlePath === undefined ||
|
||||
storageRoot === undefined ||
|
||||
hash === undefined ||
|
||||
bundlePath === "" ||
|
||||
storageRoot === "" ||
|
||||
hash === ""
|
||||
) {
|
||||
bootLog("H7XN4MKQ", "worker usage: worker <bundlePath> <storageRoot> <hash>");
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureUncagedWorkflowSymlink(storageRoot);
|
||||
// Dynamic import required: user bundle path resolved at runtime
|
||||
const modUnknown: unknown = await importWorkflowBundleModule(bundlePath);
|
||||
const modRec = modUnknown as Record<string, unknown>;
|
||||
const runExport = modRec.run;
|
||||
if (!isWorkflowFnLike(runExport)) {
|
||||
bootLog("T4BW9YJX", "workflow bundle must export run as a function (AsyncGenerator workflow)");
|
||||
process.exit(2);
|
||||
return;
|
||||
}
|
||||
const workflowFn = runExport;
|
||||
|
||||
const threads = new Map<string, ThreadHandle>();
|
||||
let activeThreads = 0;
|
||||
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cas = createCasStore(getGlobalCasDir(storageRoot));
|
||||
|
||||
const workerCtlPath = join(storageRoot, "workers", `${hash}.json`);
|
||||
|
||||
function cancelShutdownTimer(): void {
|
||||
if (shutdownTimer !== null) {
|
||||
clearTimeout(shutdownTimer);
|
||||
shutdownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleShutdown(): void {
|
||||
cancelShutdownTimer();
|
||||
shutdownTimer = setTimeout(() => {
|
||||
void unlink(workerCtlPath).catch(() => {});
|
||||
process.exit(0);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function bumpStart(): void {
|
||||
cancelShutdownTimer();
|
||||
activeThreads++;
|
||||
}
|
||||
|
||||
function bumpDone(): void {
|
||||
activeThreads--;
|
||||
if (activeThreads <= 0) {
|
||||
activeThreads = 0;
|
||||
scheduleShutdown();
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchCommand(cmd: ControlCommand, socket: Socket | null): Promise<void> {
|
||||
if (cmd.type !== "run") {
|
||||
dispatchThreadLifecycleCommand(threads, socket, cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
bumpStart();
|
||||
|
||||
const threadId = cmd.threadId;
|
||||
const runningPath = join(storageRoot, "logs", hash, `${threadId}.running`);
|
||||
const dataJsonlPath = join(storageRoot, "logs", hash, `${threadId}.data.jsonl`);
|
||||
const infoJsonlPath = join(storageRoot, "logs", hash, `${threadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId,
|
||||
hash,
|
||||
dataJsonlPath,
|
||||
infoJsonlPath,
|
||||
cas,
|
||||
};
|
||||
|
||||
const existing = threads.get(threadId);
|
||||
if (existing !== undefined) {
|
||||
existing.abortController.abort();
|
||||
threads.delete(threadId);
|
||||
}
|
||||
|
||||
const pauseGate = createThreadPauseGate();
|
||||
const ac = new AbortController();
|
||||
threads.set(threadId, { abortController: ac, pauseGate });
|
||||
|
||||
try {
|
||||
await mkdir(dirname(runningPath), { recursive: true });
|
||||
await mkdir(dirname(dataJsonlPath), { recursive: true });
|
||||
await writeFile(runningPath, "", "utf8");
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
|
||||
const baseTs = Date.now();
|
||||
let prefilledDiskSteps: PrefilledDiskStep[] | null = null;
|
||||
if (cmd.steps.length > 0) {
|
||||
prefilledDiskSteps = cmd.steps.map((step, i) => {
|
||||
const ts = cmd.stepTimestamps?.[i];
|
||||
return {
|
||||
role: step.role,
|
||||
contentHash: step.contentHash,
|
||||
meta: step.meta,
|
||||
refs: normalizeRefsField(step.refs),
|
||||
timestamp: typeof ts === "number" && ts > 0 ? ts : baseTs + i,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const runResult = await executeThread(
|
||||
workflowFn,
|
||||
cmd.workflowName,
|
||||
{ prompt: cmd.prompt, steps: cmd.steps },
|
||||
{
|
||||
...cmd.options,
|
||||
signal: ac.signal,
|
||||
awaitAfterEachYield: () => pauseGate.awaitAfterYield(),
|
||||
forkSourceThreadId: cmd.forkSourceThreadId,
|
||||
prefilledDiskSteps,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
await appendFile(dataJsonlPath, `${JSON.stringify(runResult)}\n`, "utf8");
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
bootLog("Q3MN8YKW", `thread ${threadId} failed: ${message}`);
|
||||
const failure: WorkflowResult = { returnCode: 1, summary: message, rootHash: "" };
|
||||
await appendFile(dataJsonlPath, `${JSON.stringify(failure)}\n`, "utf8").catch(() => {});
|
||||
} finally {
|
||||
threads.delete(threadId);
|
||||
await unlink(runningPath).catch(() => {});
|
||||
bumpDone();
|
||||
socket?.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof process.send === "function") {
|
||||
process.on("message", (msg: unknown) => {
|
||||
const cmd = parseControlPayload(msg);
|
||||
if (cmd === null) {
|
||||
return;
|
||||
}
|
||||
void dispatchCommand(cmd, null);
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer((socket: Socket) => {
|
||||
void (async () => {
|
||||
const line = await readLineFromSocket(socket);
|
||||
if (line === null) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
const cmd = parseCommandLine(line);
|
||||
if (cmd === null) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
await dispatchCommand(cmd, socket);
|
||||
})();
|
||||
});
|
||||
|
||||
server.on("error", (errObj: Error) => {
|
||||
bootLog("W8YK4NPX", `worker server error: ${errObj.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const addr = server.address();
|
||||
if (addr === null || typeof addr === "string") {
|
||||
bootLog("R9XK4MNW", "worker failed to bind TCP address");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`READY ${addr.port}\n`);
|
||||
|
||||
await new Promise<void>(() => {});
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -1,136 +0,0 @@
|
||||
import type { ExtractContext, ExtractFn, LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
import { type CasStore, getContentMerklePayload } from "../cas/index.js";
|
||||
import { createLlmFn, createThreadReactor } from "../reactor/index.js";
|
||||
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
|
||||
|
||||
export type ExtractDeps = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
const MAX_REACT_ROUNDS = 10;
|
||||
|
||||
const CAS_GET_TOOL_DEFINITION = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "cas_get",
|
||||
description:
|
||||
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hash: { type: "string", description: "The CAS hash to retrieve" },
|
||||
},
|
||||
required: ["hash"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type ExtractThreadContext = {
|
||||
cas: CasStore;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
|
||||
export async function buildExtractUserContent(
|
||||
ctx: ExtractContext,
|
||||
prompt: string,
|
||||
deps: ExtractDeps,
|
||||
): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Role: ${ctx.currentRole.name}`);
|
||||
lines.push(ctx.currentRole.systemPrompt);
|
||||
lines.push("");
|
||||
lines.push("## Task");
|
||||
lines.push(ctx.start.content);
|
||||
lines.push("");
|
||||
if (ctx.steps.length > 0) {
|
||||
lines.push("## Thread History");
|
||||
for (const step of ctx.steps) {
|
||||
const body = await getContentMerklePayload(deps.cas, step.contentHash);
|
||||
if (body === null) {
|
||||
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
|
||||
}
|
||||
lines.push(`### ${step.role}`);
|
||||
lines.push(body);
|
||||
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("## Agent Output");
|
||||
lines.push(ctx.agentContent);
|
||||
lines.push("");
|
||||
lines.push("## Extraction Instruction");
|
||||
lines.push(prompt);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ExtractFn backed by an LLM provider.
|
||||
*
|
||||
* Internally runs a multi-turn ReAct loop with two tools (`cas_get` for traversing the
|
||||
* Merkle DAG and a schema-shaped extract tool); the loop also accepts a plain-JSON
|
||||
* assistant reply as a short-circuit, which covers the legacy "single" extraction path.
|
||||
*/
|
||||
export function createExtract(provider: LlmProvider, deps: ExtractDeps): ExtractFn {
|
||||
const llm = createLlmFn(provider);
|
||||
const reactor = createThreadReactor<ExtractThreadContext>({
|
||||
llm,
|
||||
maxRounds: MAX_REACT_ROUNDS,
|
||||
staticTools: [CAS_GET_TOOL_DEFINITION],
|
||||
structuredToolFromSchema: (schema) => {
|
||||
const t = extractFunctionToolFromZodSchema(schema);
|
||||
return {
|
||||
name: t.name,
|
||||
tool: {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
systemPromptForStructuredTool: (structuredToolName) =>
|
||||
`You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${structuredToolName} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`,
|
||||
toolHandler: async (call, thread) => {
|
||||
if (call.function.name !== "cas_get") {
|
||||
return `Unexpected tool routed to handler: ${call.function.name}`;
|
||||
}
|
||||
let hash: string;
|
||||
try {
|
||||
const ta = JSON.parse(call.function.arguments) as unknown;
|
||||
if (!isRecord(ta) || typeof ta.hash !== "string") {
|
||||
return 'cas_get requires a JSON object with a string "hash" field.';
|
||||
}
|
||||
hash = ta.hash;
|
||||
} catch {
|
||||
return 'cas_get arguments were not valid JSON. Provide {"hash": "<cas-hash>"}.';
|
||||
}
|
||||
const blob = await thread.cas.get(hash);
|
||||
return blob === null ? "null" : blob;
|
||||
},
|
||||
});
|
||||
|
||||
return async <T extends Record<string, unknown>>(
|
||||
schema: z.ZodType<T>,
|
||||
prompt: string,
|
||||
ctx: ExtractContext,
|
||||
): Promise<T> => {
|
||||
const text = await buildExtractUserContent(ctx, prompt, deps);
|
||||
const result = await reactor({
|
||||
thread: { cas: deps.cas },
|
||||
input: text,
|
||||
schema,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`extract failed: ${result.error}`);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export {
|
||||
buildExtractUserContent,
|
||||
createExtract,
|
||||
type ExtractThreadContext,
|
||||
} from "./extract-fn.js";
|
||||
export {
|
||||
extractFunctionToolFromZodSchema,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./llm-extract.js";
|
||||
export type { ExtractFn, LlmError, LlmExtractArgs } from "./types.js";
|
||||
@@ -1,194 +0,0 @@
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type { LlmError, LlmExtractArgs } from "./types.js";
|
||||
|
||||
function chatCompletionsUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stripJsonSchemaMeta(json: Record<string, unknown>): Record<string, unknown> {
|
||||
const { $schema: _drop, ...rest } = json;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function readToolName(parametersSchema: Record<string, unknown>): string {
|
||||
const title = parametersSchema.title;
|
||||
if (typeof title === "string" && title.trim().length > 0) {
|
||||
return title.trim();
|
||||
}
|
||||
return "extract";
|
||||
}
|
||||
|
||||
function readToolDescription(parametersSchema: Record<string, unknown>): string {
|
||||
const d = parametersSchema.description;
|
||||
if (typeof d === "string" && d.trim().length > 0) {
|
||||
return d.trim();
|
||||
}
|
||||
return "Extract structured data from the input text.";
|
||||
}
|
||||
|
||||
/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */
|
||||
export function extractFunctionToolFromZodSchema(schema: z.ZodType<unknown>): {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
} {
|
||||
const rawJsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
|
||||
const parameters = stripJsonSchemaMeta(rawJsonSchema);
|
||||
return {
|
||||
name: readToolName(parameters),
|
||||
description: readToolDescription(parameters),
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
|
||||
if (!isRecord(parsed)) {
|
||||
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
|
||||
}
|
||||
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const first = choices[0];
|
||||
if (!isRecord(first)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const messageObj = first.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const toolCalls = messageObj.tool_calls;
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const call0 = toolCalls[0];
|
||||
if (!isRecord(call0)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const fn = call0.function;
|
||||
if (!isRecord(fn)) {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
const argsRaw = fn.arguments;
|
||||
if (typeof argsRaw !== "string") {
|
||||
return err({ kind: "no_tool_call", preview: previewSource.slice(0, 500) });
|
||||
}
|
||||
|
||||
return ok(argsRaw);
|
||||
}
|
||||
|
||||
export function llmErrorToCause(error: LlmError): Error {
|
||||
switch (error.kind) {
|
||||
case "http_error":
|
||||
return new Error(`HTTP ${error.status}: ${error.body.slice(0, 500)}`);
|
||||
case "invalid_response_json":
|
||||
return new Error(error.message);
|
||||
case "no_tool_call":
|
||||
return new Error(`No tool call in response: ${error.preview}`);
|
||||
case "tool_arguments_invalid_json":
|
||||
return new Error(error.message);
|
||||
case "schema_validation_failed":
|
||||
return new Error(error.message);
|
||||
case "network_error":
|
||||
return new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function performLlmExtract<T>(
|
||||
options: LlmExtractArgs<T> & { userContent: string },
|
||||
): Promise<Result<T, LlmError>> {
|
||||
const extractTool = extractFunctionToolFromZodSchema(options.schema);
|
||||
|
||||
const body = {
|
||||
model: options.provider.model,
|
||||
messages: [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: "Extract the requested information from the provided text. Be precise.",
|
||||
},
|
||||
{ role: "user" as const, content: options.userContent },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: extractTool.name,
|
||||
description: extractTool.description,
|
||||
parameters: extractTool.parameters,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function" as const, function: { name: extractTool.name } },
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(chatCompletionsUrl(options.provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "network_error", message });
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return err({ kind: "http_error", status: response.status, body: responseText.slice(0, 4000) });
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "invalid_response_json", message });
|
||||
}
|
||||
|
||||
const argsJson = readToolArgumentsJson(parsed, responseText);
|
||||
if (!argsJson.ok) {
|
||||
return argsJson;
|
||||
}
|
||||
|
||||
let argsParsed: unknown;
|
||||
try {
|
||||
argsParsed = JSON.parse(argsJson.value) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err({ kind: "tool_arguments_invalid_json", message });
|
||||
}
|
||||
|
||||
const validated = options.schema.safeParse(argsParsed);
|
||||
if (!validated.success) {
|
||||
return err({
|
||||
kind: "schema_validation_failed",
|
||||
message: validated.error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(validated.data);
|
||||
}
|
||||
|
||||
/** Single LLM extract attempt over OpenAI-compatible chat completions with forced tool call. */
|
||||
export async function llmExtract<T>(options: LlmExtractArgs<T>): Promise<Result<T, LlmError>> {
|
||||
return performLlmExtract({ ...options, userContent: options.text });
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
export type { ExtractFn } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type LlmExtractArgs<T> = {
|
||||
text: string;
|
||||
schema: z.ZodType<T>;
|
||||
provider: LlmProvider;
|
||||
};
|
||||
|
||||
export type LlmError =
|
||||
| { kind: "http_error"; status: number; body: string }
|
||||
| { kind: "invalid_response_json"; message: string }
|
||||
| { kind: "no_tool_call"; preview: string }
|
||||
| { kind: "tool_arguments_invalid_json"; message: string }
|
||||
| { kind: "schema_validation_failed"; message: string }
|
||||
| { kind: "network_error"; message: string };
|
||||
@@ -1,109 +0,0 @@
|
||||
export {
|
||||
buildDescriptor,
|
||||
type ExtractedBundleExports,
|
||||
extractBundleExports,
|
||||
stringifyWorkflowDescriptor,
|
||||
validateWorkflowBundle,
|
||||
validateWorkflowDescriptor,
|
||||
type WorkflowBundleValidationInput,
|
||||
type WorkflowDescriptor,
|
||||
type WorkflowRoleDescriptor,
|
||||
type WorkflowRoleSchema,
|
||||
} from "./bundle/index.js";
|
||||
export {
|
||||
type CasStore,
|
||||
createCasStore,
|
||||
createContentMerkleNode,
|
||||
getContentMerklePayload,
|
||||
hashString,
|
||||
hashWorkflowBundleBytes,
|
||||
type MerkleNode,
|
||||
type MerkleNodeType,
|
||||
parseMerkleNode,
|
||||
putContentMerkleNode,
|
||||
putStepMerkleNode,
|
||||
putThreadMerkleNode,
|
||||
type StepMerklePayload,
|
||||
serializeMerkleNode,
|
||||
type ThreadMerklePayload,
|
||||
} from "./cas/index.js";
|
||||
export {
|
||||
type ProviderConfig,
|
||||
type ResolvedModel,
|
||||
resolveModel,
|
||||
} from "./config/index.js";
|
||||
export {
|
||||
buildForkPlan,
|
||||
createThreadPauseGate,
|
||||
createWorkflow,
|
||||
type ExecuteThreadIo,
|
||||
type ExecuteThreadOptions,
|
||||
executeThread,
|
||||
type ForkHistoricalStep,
|
||||
type ForkPlan,
|
||||
type GcResult,
|
||||
garbageCollectCas,
|
||||
getWorkerHostScriptPath,
|
||||
type ParsedThreadStartRecord,
|
||||
type PrefilledDiskStep,
|
||||
parseThreadDataJsonl,
|
||||
type SupervisorDecision,
|
||||
selectForkHistoricalSteps,
|
||||
type ThreadPauseGate,
|
||||
tryParseRoleStepRecord,
|
||||
tryParseWorkflowResultRecord,
|
||||
} from "./engine/index.js";
|
||||
export {
|
||||
createExtract,
|
||||
type ExtractFn,
|
||||
type ExtractThreadContext,
|
||||
type LlmError,
|
||||
llmErrorToCause,
|
||||
llmExtract,
|
||||
} from "./extract/index.js";
|
||||
export {
|
||||
type ChatMessage,
|
||||
createLlmFn,
|
||||
createThreadReactor,
|
||||
type LlmFn,
|
||||
type StructuredToolSpec,
|
||||
type ThreadReactorConfig,
|
||||
type ThreadReactorFn,
|
||||
type ThreadReactorInvokeArgs,
|
||||
type ToolCall,
|
||||
type ToolDefinition,
|
||||
} from "./reactor/index.js";
|
||||
export {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
stringifyWorkflowRegistryYaml,
|
||||
unregisterWorkflow,
|
||||
type WorkflowConfig,
|
||||
type WorkflowHistoryEntry,
|
||||
type WorkflowRegistryEntry,
|
||||
type WorkflowRegistryFile,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "./registry/index.js";
|
||||
export {
|
||||
CROCKFORD_BASE32_ALPHABET,
|
||||
type CreateLoggerOptions,
|
||||
createLogger,
|
||||
decodeCrockfordBase32Bits,
|
||||
decodeCrockfordToUint64,
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
err,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
type LogFn,
|
||||
type LoggerSink,
|
||||
ok,
|
||||
type Result,
|
||||
} from "./util/index.js";
|
||||
export { type WorkflowAsAgentOptions, workflowAsAgent } from "./workflow-as-agent.js";
|
||||
@@ -1,12 +0,0 @@
|
||||
export { createLlmFn } from "./llm-fn.js";
|
||||
export { createThreadReactor } from "./thread-reactor.js";
|
||||
export type {
|
||||
ChatMessage,
|
||||
LlmFn,
|
||||
StructuredToolSpec,
|
||||
ThreadReactorConfig,
|
||||
ThreadReactorFn,
|
||||
ThreadReactorInvokeArgs,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
} from "./types.js";
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { LlmProvider } from "@uncaged/workflow-runtime";
|
||||
|
||||
import { err, ok } from "../util/index.js";
|
||||
|
||||
import type { ChatMessage, LlmFn, ToolDefinition } from "./types.js";
|
||||
|
||||
function chatCompletionsUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.replace(/\/+$/, "");
|
||||
return `${trimmed}/chat/completions`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps provider credentials into an {@link LlmFn}: single POST to chat/completions,
|
||||
* returns raw JSON body text or a {@link Result} error. Callers parse assistant messages.
|
||||
*/
|
||||
export function createLlmFn(provider: LlmProvider): LlmFn {
|
||||
return async ({
|
||||
messages,
|
||||
tools,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
tools: readonly ToolDefinition[];
|
||||
}) => {
|
||||
try {
|
||||
const response = await fetch(chatCompletionsUrl(provider.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${provider.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: provider.model,
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
return err(`http_error:${String(response.status)}:${responseText.slice(0, 4000)}`);
|
||||
}
|
||||
return ok(responseText);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err(`network_error:${message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
|
||||
import type {
|
||||
ChatMessage,
|
||||
StructuredToolSpec,
|
||||
ThreadReactorConfig,
|
||||
ThreadReactorFn,
|
||||
ToolCall,
|
||||
ToolDefinition,
|
||||
} from "./types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function tryParseJsonContent(content: string): unknown | null {
|
||||
const trimmed = content.trim();
|
||||
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
|
||||
const payload = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
|
||||
try {
|
||||
return JSON.parse(payload) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function firstAssistantMessage(responseText: string): Result<Record<string, unknown>, string> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(responseText) as unknown;
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
return err(`invalid_response_json:${message}`);
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
return err("invalid_response_top_level");
|
||||
}
|
||||
const choices = parsed.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
return err("no_choices_in_response");
|
||||
}
|
||||
const firstChoice = choices[0];
|
||||
if (!isRecord(firstChoice)) {
|
||||
return err("invalid_choice");
|
||||
}
|
||||
const messageObj = firstChoice.message;
|
||||
if (!isRecord(messageObj)) {
|
||||
return err("invalid_message");
|
||||
}
|
||||
return ok(messageObj);
|
||||
}
|
||||
|
||||
function normalizeToolCalls(toolCallsRaw: unknown[]): Result<ToolCall[], string> {
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const tc of toolCallsRaw) {
|
||||
if (!isRecord(tc)) {
|
||||
return err("invalid_tool_call");
|
||||
}
|
||||
const id = tc.id;
|
||||
const tcType = tc.type;
|
||||
const fn = tc.function;
|
||||
if (typeof id !== "string" || tcType !== "function" || !isRecord(fn)) {
|
||||
return err("invalid_tool_call_shape");
|
||||
}
|
||||
const name = fn.name;
|
||||
const argumentsStr = fn.arguments;
|
||||
if (typeof name !== "string" || typeof argumentsStr !== "string") {
|
||||
return err("invalid_tool_call_function");
|
||||
}
|
||||
toolCalls.push({ id, type: "function", function: { name, arguments: argumentsStr } });
|
||||
}
|
||||
return ok(toolCalls);
|
||||
}
|
||||
|
||||
type AssistantTurn<T> =
|
||||
| { kind: "plain_json"; value: T }
|
||||
| { kind: "tool_calls"; calls: ToolCall[]; assistantContent: string | null };
|
||||
|
||||
type AssistantTurnOrCorrection<T> =
|
||||
| AssistantTurn<T>
|
||||
| { kind: "plain_json_invalid"; rawContent: string; correction: string };
|
||||
|
||||
function classifyAssistantTurn<T>(
|
||||
messageObj: Record<string, unknown>,
|
||||
schema: z.ZodType<T>,
|
||||
structuredToolName: string,
|
||||
): Result<AssistantTurnOrCorrection<T>, string> {
|
||||
const toolCallsRaw = messageObj.tool_calls;
|
||||
if (!Array.isArray(toolCallsRaw) || toolCallsRaw.length === 0) {
|
||||
const content = messageObj.content;
|
||||
if (typeof content !== "string") {
|
||||
return err("no_tool_calls_and_no_string_content");
|
||||
}
|
||||
const jsonParsed = tryParseJsonContent(content);
|
||||
if (jsonParsed === null) {
|
||||
return ok({
|
||||
kind: "plain_json_invalid",
|
||||
rawContent: content,
|
||||
correction: `Your previous reply was not valid JSON and contained no tool calls. Reply with a single JSON object that matches the schema, or call the ${structuredToolName} tool with the structured arguments.`,
|
||||
});
|
||||
}
|
||||
const validated = schema.safeParse(jsonParsed);
|
||||
if (!validated.success) {
|
||||
return ok({
|
||||
kind: "plain_json_invalid",
|
||||
rawContent: content,
|
||||
correction: `Your previous JSON reply did not satisfy the schema: ${validated.error.message}. Reply again with a JSON object that matches the schema, or call the ${structuredToolName} tool with the structured arguments.`,
|
||||
});
|
||||
}
|
||||
return ok({ kind: "plain_json", value: validated.data });
|
||||
}
|
||||
const callsResult = normalizeToolCalls(toolCallsRaw);
|
||||
if (!callsResult.ok) {
|
||||
return err(callsResult.error);
|
||||
}
|
||||
const assistantContent = messageObj.content;
|
||||
return ok({
|
||||
kind: "tool_calls",
|
||||
calls: callsResult.value,
|
||||
assistantContent: typeof assistantContent === "string" ? assistantContent : null,
|
||||
});
|
||||
}
|
||||
|
||||
function toolNamesFromDefinitions(tools: readonly { function: { name: string } }[]): Set<string> {
|
||||
return new Set(tools.map((t) => t.function.name));
|
||||
}
|
||||
|
||||
function appendStructuredToolResult<T>(
|
||||
tc: ToolCall,
|
||||
schema: z.ZodType<T>,
|
||||
messages: ChatMessage[],
|
||||
): T | null {
|
||||
let parsedArgs: unknown;
|
||||
try {
|
||||
parsedArgs = JSON.parse(tc.function.arguments) as unknown;
|
||||
} catch {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content:
|
||||
"Tool arguments were not valid JSON. Provide valid JSON object arguments matching the schema.",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const validated = schema.safeParse(parsedArgs);
|
||||
if (!validated.success) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: `Schema validation failed: ${validated.error.message}. Fix the arguments and call the tool again with a JSON object that matches the schema.`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: '{"ok":true}',
|
||||
});
|
||||
return validated.data;
|
||||
}
|
||||
|
||||
async function dispatchToolCall<T, TThread>(
|
||||
tc: ToolCall,
|
||||
spec: StructuredToolSpec,
|
||||
knownNames: Set<string>,
|
||||
schema: z.ZodType<T>,
|
||||
thread: TThread,
|
||||
toolHandler: ThreadReactorConfig<TThread>["toolHandler"],
|
||||
messages: ChatMessage[],
|
||||
): Promise<T | null> {
|
||||
if (!knownNames.has(tc.function.name)) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: `Unknown tool: ${tc.function.name}. Use one of the declared tools only.`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (tc.function.name === spec.name) {
|
||||
return appendStructuredToolResult(tc, schema, messages);
|
||||
}
|
||||
let toolContent: string;
|
||||
try {
|
||||
toolContent = await toolHandler(tc, thread);
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
toolContent = `Tool execution failed: ${message}`;
|
||||
}
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: toolContent,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveToolCallRound<T, TThread>(
|
||||
turn: Extract<AssistantTurn<T>, { kind: "tool_calls" }>,
|
||||
spec: StructuredToolSpec,
|
||||
knownNames: Set<string>,
|
||||
schema: z.ZodType<T>,
|
||||
thread: TThread,
|
||||
toolHandler: ThreadReactorConfig<TThread>["toolHandler"],
|
||||
messages: ChatMessage[],
|
||||
): Promise<Result<T, string> | null> {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: turn.assistantContent,
|
||||
tool_calls: turn.calls,
|
||||
});
|
||||
let extractedRound: T | null = null;
|
||||
for (const tc of turn.calls) {
|
||||
const extracted = await dispatchToolCall(
|
||||
tc,
|
||||
spec,
|
||||
knownNames,
|
||||
schema,
|
||||
thread,
|
||||
toolHandler,
|
||||
messages,
|
||||
);
|
||||
if (extracted !== null) {
|
||||
extractedRound = extracted;
|
||||
}
|
||||
}
|
||||
if (extractedRound !== null) {
|
||||
return ok(extractedRound);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function runOneReactRound<T, TThread>(
|
||||
config: ThreadReactorConfig<TThread>,
|
||||
args: { thread: TThread; schema: z.ZodType<T> },
|
||||
tools: readonly ToolDefinition[],
|
||||
knownNames: Set<string>,
|
||||
spec: StructuredToolSpec,
|
||||
messages: ChatMessage[],
|
||||
): Promise<Result<T, string> | null> {
|
||||
const bodyResult = await config.llm({ messages, tools });
|
||||
if (!bodyResult.ok) {
|
||||
return bodyResult;
|
||||
}
|
||||
|
||||
const msgResult = firstAssistantMessage(bodyResult.value);
|
||||
if (!msgResult.ok) {
|
||||
return msgResult;
|
||||
}
|
||||
|
||||
const classified = classifyAssistantTurn(msgResult.value, args.schema, spec.name);
|
||||
if (!classified.ok) {
|
||||
return classified;
|
||||
}
|
||||
|
||||
const turn = classified.value;
|
||||
if (turn.kind === "plain_json") {
|
||||
return ok(turn.value);
|
||||
}
|
||||
|
||||
if (turn.kind === "plain_json_invalid") {
|
||||
messages.push({ role: "assistant", content: turn.rawContent });
|
||||
messages.push({ role: "user", content: turn.correction });
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveToolCallRound(
|
||||
turn,
|
||||
spec,
|
||||
knownNames,
|
||||
args.schema,
|
||||
args.thread,
|
||||
config.toolHandler,
|
||||
messages,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic ReAct loop: LLM round-trips with tools until structured output validates,
|
||||
* plain JSON matches schema, or {@link ThreadReactorConfig.maxRounds} is exceeded.
|
||||
*/
|
||||
export function createThreadReactor<TThread>(
|
||||
config: ThreadReactorConfig<TThread>,
|
||||
): ThreadReactorFn<TThread> {
|
||||
return async <T>(args: {
|
||||
thread: TThread;
|
||||
input: string;
|
||||
schema: z.ZodType<T>;
|
||||
}): Promise<Result<T, string>> => {
|
||||
const spec = config.structuredToolFromSchema(args.schema);
|
||||
const tools = [...config.staticTools, spec.tool];
|
||||
const knownNames = toolNamesFromDefinitions(tools);
|
||||
const systemPrompt = config.systemPromptForStructuredTool(spec.name);
|
||||
|
||||
const messages: ChatMessage[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: args.input },
|
||||
];
|
||||
|
||||
for (let round = 0; round < config.maxRounds; round++) {
|
||||
const step = await runOneReactRound(
|
||||
config,
|
||||
{ thread: args.thread, schema: args.schema },
|
||||
tools,
|
||||
knownNames,
|
||||
spec,
|
||||
messages,
|
||||
);
|
||||
if (step !== null) {
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
return err("max_react_rounds_exceeded");
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type * as z from "zod/v4";
|
||||
|
||||
import type { Result } from "../util/index.js";
|
||||
|
||||
export type ToolCall = {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
};
|
||||
|
||||
export type ToolDefinition = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ChatMessage =
|
||||
| { role: "system"; content: string }
|
||||
| { role: "user"; content: string }
|
||||
| {
|
||||
role: "assistant";
|
||||
content: string | null;
|
||||
tool_calls: ToolCall[];
|
||||
}
|
||||
| { role: "assistant"; content: string }
|
||||
| { role: "tool"; tool_call_id: string; content: string };
|
||||
|
||||
export type LlmFn = (input: {
|
||||
messages: ChatMessage[];
|
||||
tools: readonly ToolDefinition[];
|
||||
}) => Promise<Result<string, string>>;
|
||||
|
||||
/** Structured tool derived from the per-invocation Zod schema (e.g. extract tool). */
|
||||
export type StructuredToolSpec = {
|
||||
name: string;
|
||||
tool: ToolDefinition;
|
||||
};
|
||||
|
||||
export type ThreadReactorConfig<TThread> = {
|
||||
llm: LlmFn;
|
||||
/** Static tools (e.g. cas_get); structured tool is appended per invocation. */
|
||||
staticTools: readonly ToolDefinition[];
|
||||
/** Builds the schema-shaped tool and its OpenAI name for this invocation. */
|
||||
structuredToolFromSchema: (schema: z.ZodType<unknown>) => StructuredToolSpec;
|
||||
/** System prompt for this run; include the structured tool name for cache stability per schema. */
|
||||
systemPromptForStructuredTool: (structuredToolName: string) => string;
|
||||
toolHandler: (call: ToolCall, thread: TThread) => Promise<string>;
|
||||
maxRounds: number;
|
||||
};
|
||||
|
||||
export type ThreadReactorInvokeArgs<TThread, T> = {
|
||||
thread: TThread;
|
||||
input: string;
|
||||
schema: z.ZodType<T>;
|
||||
};
|
||||
|
||||
export type ThreadReactorFn<TThread> = <T>(
|
||||
args: ThreadReactorInvokeArgs<TThread, T>,
|
||||
) => Promise<Result<T, string>>;
|
||||
@@ -1,18 +0,0 @@
|
||||
export {
|
||||
getRegisteredWorkflow,
|
||||
listRegisteredWorkflowNames,
|
||||
parseWorkflowRegistryYaml,
|
||||
readWorkflowRegistry,
|
||||
registerWorkflowVersion,
|
||||
rollbackWorkflowToHistoryHash,
|
||||
stringifyWorkflowRegistryYaml,
|
||||
unregisterWorkflow,
|
||||
workflowRegistryPath,
|
||||
writeWorkflowRegistry,
|
||||
} from "./registry.js";
|
||||
export type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./types.js";
|
||||
@@ -1,224 +0,0 @@
|
||||
import { type ProviderConfig, splitProviderModelRef } from "../config/index.js";
|
||||
import { createLogger, err, ok, type Result } from "../util/index.js";
|
||||
import type {
|
||||
WorkflowConfig,
|
||||
WorkflowHistoryEntry,
|
||||
WorkflowRegistryEntry,
|
||||
WorkflowRegistryFile,
|
||||
} from "./types.js";
|
||||
|
||||
const registryNormalizeLog = createLogger({ sink: { kind: "stderr" } });
|
||||
|
||||
function resolveRegistryApiKey(raw: string, ctx: string): Result<string, Error> {
|
||||
if (raw.startsWith("env:")) {
|
||||
const name = raw.slice("env:".length);
|
||||
if (name === "") {
|
||||
return err(new Error(`${ctx}: "env:" apiKey reference must name a variable`));
|
||||
}
|
||||
const value = process.env[name];
|
||||
if (value === undefined) {
|
||||
return err(new Error(`${ctx}: environment variable "${name}" is not set`));
|
||||
}
|
||||
return ok(value);
|
||||
}
|
||||
return ok(raw);
|
||||
}
|
||||
|
||||
function normalizeProviderEntry(name: string, entryRaw: unknown): Result<ProviderConfig, Error> {
|
||||
if (name === "") {
|
||||
return err(new Error("config.providers must not contain an empty provider name"));
|
||||
}
|
||||
if (entryRaw === null || typeof entryRaw !== "object" || Array.isArray(entryRaw)) {
|
||||
return err(new Error(`config.providers.${name} must be a mapping`));
|
||||
}
|
||||
const e = entryRaw as Record<string, unknown>;
|
||||
const baseUrl = e.baseUrl;
|
||||
const apiKeyRaw = e.apiKey;
|
||||
if (typeof baseUrl !== "string" || baseUrl === "") {
|
||||
return err(new Error(`config.providers.${name}.baseUrl must be a non-empty string`));
|
||||
}
|
||||
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
|
||||
return err(new Error(`config.providers.${name}.apiKey must be a non-empty string`));
|
||||
}
|
||||
const apiKeyCtx = `config.providers.${name}.apiKey`;
|
||||
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw, apiKeyCtx);
|
||||
if (!apiKeyResult.ok) {
|
||||
return apiKeyResult;
|
||||
}
|
||||
return ok({ baseUrl, apiKey: apiKeyResult.value });
|
||||
}
|
||||
|
||||
function normalizeProviders(raw: unknown): Result<Record<string, ProviderConfig>, Error> {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return err(new Error('registry config must contain a "providers" mapping'));
|
||||
}
|
||||
const root = raw as Record<string, unknown>;
|
||||
const providers: Record<string, ProviderConfig> = {};
|
||||
for (const [name, entryRaw] of Object.entries(root)) {
|
||||
const next = normalizeProviderEntry(name, entryRaw);
|
||||
if (!next.ok) {
|
||||
return next;
|
||||
}
|
||||
providers[name] = next.value;
|
||||
}
|
||||
return ok(providers);
|
||||
}
|
||||
|
||||
function normalizeModels(
|
||||
raw: unknown,
|
||||
providers: Record<string, ProviderConfig>,
|
||||
): Result<Record<string, string>, Error> {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return err(new Error('registry config must contain a "models" mapping'));
|
||||
}
|
||||
const root = raw as Record<string, unknown>;
|
||||
const models: Record<string, string> = {};
|
||||
const providerKeys = new Set(Object.keys(providers));
|
||||
for (const [scene, refRaw] of Object.entries(root)) {
|
||||
if (scene === "") {
|
||||
return err(new Error("config.models must not contain an empty scene name"));
|
||||
}
|
||||
if (typeof refRaw !== "string" || refRaw === "") {
|
||||
return err(new Error(`config.models.${scene} must be a non-empty string (provider/model)`));
|
||||
}
|
||||
const ctx = `config.models.${scene}`;
|
||||
const parsed = splitProviderModelRef(refRaw);
|
||||
if (!parsed.ok) {
|
||||
return err(new Error(`${ctx}: ${parsed.error}`));
|
||||
}
|
||||
if (!providerKeys.has(parsed.value.providerName)) {
|
||||
return err(
|
||||
new Error(
|
||||
`${ctx}: unknown provider "${parsed.value.providerName}" (not listed under config.providers)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
models[scene] = refRaw;
|
||||
}
|
||||
if (!Object.hasOwn(models, "default")) {
|
||||
registryNormalizeLog(
|
||||
"Z2KP9NWQ",
|
||||
'registry config: models mapping has no "default" key; scenes without explicit model mappings may fail at resolveModel',
|
||||
);
|
||||
}
|
||||
return ok(models);
|
||||
}
|
||||
|
||||
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error('registry "config" must be a mapping'));
|
||||
}
|
||||
const c = raw as Record<string, unknown>;
|
||||
const maxDepth = c.maxDepth;
|
||||
const supervisorIntervalRaw = c.supervisorInterval;
|
||||
const providersRaw = c.providers;
|
||||
const modelsRaw = c.models;
|
||||
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
|
||||
return err(new Error("config.maxDepth must be a non-negative integer"));
|
||||
}
|
||||
let supervisorInterval = 3;
|
||||
if (supervisorIntervalRaw !== undefined) {
|
||||
if (
|
||||
typeof supervisorIntervalRaw !== "number" ||
|
||||
!Number.isInteger(supervisorIntervalRaw) ||
|
||||
supervisorIntervalRaw < 0
|
||||
) {
|
||||
return err(new Error("config.supervisorInterval must be a non-negative integer"));
|
||||
}
|
||||
supervisorInterval = supervisorIntervalRaw;
|
||||
}
|
||||
const providersResult = normalizeProviders(providersRaw);
|
||||
if (!providersResult.ok) {
|
||||
return providersResult;
|
||||
}
|
||||
const modelsResult = normalizeModels(modelsRaw, providersResult.value);
|
||||
if (!modelsResult.ok) {
|
||||
return modelsResult;
|
||||
}
|
||||
return ok({
|
||||
maxDepth,
|
||||
supervisorInterval,
|
||||
providers: providersResult.value,
|
||||
models: modelsResult.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeWorkflowHistoryEntry(
|
||||
workflowName: string,
|
||||
index: number,
|
||||
raw: unknown,
|
||||
): Result<WorkflowHistoryEntry, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error(`workflow "${workflowName}" history[${index}] must be a mapping`));
|
||||
}
|
||||
const he = raw as Record<string, unknown>;
|
||||
const hash = he.hash;
|
||||
const timestamp = he.timestamp;
|
||||
if (typeof hash !== "string" || typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
||||
return err(
|
||||
new Error(`workflow "${workflowName}" history[${index}] must have hash and timestamp`),
|
||||
);
|
||||
}
|
||||
return ok({ hash, timestamp });
|
||||
}
|
||||
|
||||
export function normalizeWorkflowRegistryEntry(
|
||||
workflowName: string,
|
||||
raw: unknown,
|
||||
): Result<WorkflowRegistryEntry, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error(`workflow "${workflowName}" must be a mapping`));
|
||||
}
|
||||
const e = raw as Record<string, unknown>;
|
||||
const hash = e.hash;
|
||||
const timestamp = e.timestamp;
|
||||
const historyRaw = e.history;
|
||||
if (typeof hash !== "string") {
|
||||
return err(new Error(`workflow "${workflowName}" must have a string hash`));
|
||||
}
|
||||
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
||||
return err(new Error(`workflow "${workflowName}" must have a finite numeric timestamp`));
|
||||
}
|
||||
if (!Array.isArray(historyRaw)) {
|
||||
return err(new Error(`workflow "${workflowName}" must have a history array`));
|
||||
}
|
||||
const history: WorkflowHistoryEntry[] = [];
|
||||
for (let i = 0; i < historyRaw.length; i++) {
|
||||
const item = historyRaw[i];
|
||||
const next = normalizeWorkflowHistoryEntry(workflowName, i, item);
|
||||
if (!next.ok) {
|
||||
return next;
|
||||
}
|
||||
history.push(next.value);
|
||||
}
|
||||
return ok({ hash, timestamp, history });
|
||||
}
|
||||
|
||||
export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegistryFile, Error> {
|
||||
if (raw === null || typeof raw !== "object") {
|
||||
return err(new Error("registry root must be a mapping"));
|
||||
}
|
||||
const root = raw as Record<string, unknown>;
|
||||
const configRaw = root.config;
|
||||
let config: WorkflowConfig | null = null;
|
||||
if (configRaw !== undefined && configRaw !== null) {
|
||||
const configResult = normalizeWorkflowConfig(configRaw);
|
||||
if (!configResult.ok) {
|
||||
return configResult;
|
||||
}
|
||||
config = configResult.value;
|
||||
}
|
||||
const workflowsRaw = root.workflows;
|
||||
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
|
||||
return err(new Error('registry must contain a "workflows" mapping'));
|
||||
}
|
||||
const workflows: Record<string, WorkflowRegistryEntry> = {};
|
||||
for (const [name, entryRaw] of Object.entries(workflowsRaw)) {
|
||||
const entryResult = normalizeWorkflowRegistryEntry(name, entryRaw);
|
||||
if (!entryResult.ok) {
|
||||
return entryResult;
|
||||
}
|
||||
workflows[name] = entryResult.value;
|
||||
}
|
||||
return ok({ config, workflows });
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { parseDocument, stringify } from "yaml";
|
||||
import { err, ok, type Result } from "../util/index.js";
|
||||
import { normalizeWorkflowRegistryRoot } from "./registry-normalize.js";
|
||||
import type { WorkflowHistoryEntry, WorkflowRegistryEntry, WorkflowRegistryFile } from "./types.js";
|
||||
|
||||
export function workflowRegistryPath(storageRoot: string): string {
|
||||
return join(storageRoot, "workflow.yaml");
|
||||
}
|
||||
|
||||
function emptyRegistry(): WorkflowRegistryFile {
|
||||
return { config: null, workflows: {} };
|
||||
}
|
||||
|
||||
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
|
||||
if (text.trim() === "") {
|
||||
return ok(emptyRegistry());
|
||||
}
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = parseDocument(text).toJSON();
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
return normalizeWorkflowRegistryRoot(doc);
|
||||
}
|
||||
|
||||
export function stringifyWorkflowRegistryYaml(registry: WorkflowRegistryFile): string {
|
||||
return `${stringify(registry, { indent: 2, defaultStringType: "QUOTE_DOUBLE" })}\n`;
|
||||
}
|
||||
|
||||
export async function readWorkflowRegistry(
|
||||
storageRoot: string,
|
||||
): Promise<Result<WorkflowRegistryFile, Error>> {
|
||||
const path = workflowRegistryPath(storageRoot);
|
||||
let text: string;
|
||||
try {
|
||||
text = await readFile(path, "utf8");
|
||||
} catch (e) {
|
||||
const errObj = e as NodeJS.ErrnoException;
|
||||
if (errObj.code === "ENOENT") {
|
||||
return ok(emptyRegistry());
|
||||
}
|
||||
return err(errObj instanceof Error ? errObj : new Error(String(e)));
|
||||
}
|
||||
return parseWorkflowRegistryYaml(text);
|
||||
}
|
||||
|
||||
export async function writeWorkflowRegistry(
|
||||
storageRoot: string,
|
||||
registry: WorkflowRegistryFile,
|
||||
): Promise<Result<void, Error>> {
|
||||
const path = workflowRegistryPath(storageRoot);
|
||||
try {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, stringifyWorkflowRegistryYaml(registry), "utf8");
|
||||
} catch (e) {
|
||||
return err(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
export function listRegisteredWorkflowNames(registry: WorkflowRegistryFile): string[] {
|
||||
return Object.keys(registry.workflows).sort();
|
||||
}
|
||||
|
||||
export function getRegisteredWorkflow(
|
||||
registry: WorkflowRegistryFile,
|
||||
name: string,
|
||||
): WorkflowRegistryEntry | null {
|
||||
const entry = registry.workflows[name];
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Register or upgrade a workflow version, moving the previous head into `history`. */
|
||||
export function registerWorkflowVersion(
|
||||
registry: WorkflowRegistryFile,
|
||||
name: string,
|
||||
hash: string,
|
||||
timestamp: number,
|
||||
): WorkflowRegistryFile {
|
||||
const prev = registry.workflows[name];
|
||||
const baseHistory = prev === undefined ? [] : prev.history;
|
||||
const history: WorkflowHistoryEntry[] =
|
||||
prev === undefined
|
||||
? baseHistory
|
||||
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
|
||||
const next: WorkflowRegistryEntry = { hash, timestamp, history };
|
||||
return {
|
||||
config: registry.config,
|
||||
workflows: { ...registry.workflows, [name]: next },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back `entry` to a hash listed in `entry.history`.
|
||||
* When `targetHash` is null, uses the most recent history entry (`history[0]`).
|
||||
* Current head is prepended to history; the selected entry becomes the new head.
|
||||
*/
|
||||
export function rollbackWorkflowToHistoryHash(
|
||||
entry: WorkflowRegistryEntry,
|
||||
targetHash: string | null,
|
||||
): Result<WorkflowRegistryEntry, Error> {
|
||||
const resolved =
|
||||
targetHash !== null && targetHash !== ""
|
||||
? targetHash
|
||||
: entry.history[0] !== undefined
|
||||
? entry.history[0].hash
|
||||
: null;
|
||||
if (resolved === null) {
|
||||
return err(new Error("no history entry to rollback to"));
|
||||
}
|
||||
const idx = entry.history.findIndex((h) => h.hash === resolved);
|
||||
if (idx < 0) {
|
||||
return err(new Error(`hash not found in history: ${resolved}`));
|
||||
}
|
||||
const selected = entry.history[idx];
|
||||
const newHistory: WorkflowHistoryEntry[] = [
|
||||
{ hash: entry.hash, timestamp: entry.timestamp },
|
||||
...entry.history.slice(0, idx),
|
||||
...entry.history.slice(idx + 1),
|
||||
];
|
||||
return ok({
|
||||
hash: selected.hash,
|
||||
timestamp: selected.timestamp,
|
||||
history: newHistory,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterWorkflow(
|
||||
registry: WorkflowRegistryFile,
|
||||
name: string,
|
||||
): Result<WorkflowRegistryFile, Error> {
|
||||
if (registry.workflows[name] === undefined) {
|
||||
return err(new Error(`workflow not registered: ${name}`));
|
||||
}
|
||||
const { [name]: _removed, ...rest } = registry.workflows;
|
||||
return ok({ config: registry.config, workflows: rest });
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { ProviderConfig } from "../config/index.js";
|
||||
|
||||
export type WorkflowHistoryEntry = {
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type WorkflowRegistryEntry = {
|
||||
hash: string;
|
||||
timestamp: number;
|
||||
history: WorkflowHistoryEntry[];
|
||||
};
|
||||
|
||||
export type WorkflowConfig = {
|
||||
maxDepth: number;
|
||||
/** Run supervisor LLM every N completed role rounds (0 = disabled). Default from YAML: 3. */
|
||||
supervisorInterval: number;
|
||||
providers: Record<string, ProviderConfig>;
|
||||
models: Record<string, string>;
|
||||
};
|
||||
|
||||
export type WorkflowRegistryFile = {
|
||||
config: WorkflowConfig | null;
|
||||
workflows: Record<string, WorkflowRegistryEntry>;
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { err, ok } from "./result.js";
|
||||
import type { Result } from "./types.js";
|
||||
|
||||
/** Crockford Base32 alphabet (no I, L, O, U) — exactly 32 symbols. */
|
||||
export const CROCKFORD_BASE32_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
const DECODE_MAP: Record<string, number> = (() => {
|
||||
const map: Record<string, number> = {};
|
||||
for (let i = 0; i < CROCKFORD_BASE32_ALPHABET.length; i++) {
|
||||
map[CROCKFORD_BASE32_ALPHABET[i]] = i;
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
function padBitCount(bitLength: number): number {
|
||||
const r = bitLength % 5;
|
||||
return r === 0 ? 0 : 5 - r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an integer using exactly `bitLength` significant bits, MSB-first,
|
||||
* with the minimum number of leading zero bits so the total is a multiple of 5.
|
||||
*/
|
||||
export function encodeCrockfordBase32Bits(value: bigint, bitLength: number): string {
|
||||
if (bitLength <= 0) {
|
||||
throw new Error("bitLength must be positive");
|
||||
}
|
||||
const padBits = padBitCount(bitLength);
|
||||
const totalBits = bitLength + padBits;
|
||||
const charCount = totalBits / 5;
|
||||
const shifted = value << BigInt(padBits);
|
||||
let result = "";
|
||||
for (let i = 0; i < charCount; i++) {
|
||||
const shift = totalBits - 5 * (i + 1);
|
||||
const quintet = Number((shifted >> BigInt(shift)) & 31n);
|
||||
result += CROCKFORD_BASE32_ALPHABET[quintet];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function decodeCrockfordBase32Bits(
|
||||
encoded: string,
|
||||
bitLength: number,
|
||||
): Result<bigint, Error> {
|
||||
if (bitLength <= 0) {
|
||||
return err(new Error("bitLength must be positive"));
|
||||
}
|
||||
const padBits = padBitCount(bitLength);
|
||||
const totalBits = encoded.length * 5;
|
||||
if (totalBits !== bitLength + padBits) {
|
||||
return err(new Error("encoded length does not match bitLength"));
|
||||
}
|
||||
let shifted = 0n;
|
||||
for (let i = 0; i < encoded.length; i++) {
|
||||
const ch = encoded[i];
|
||||
if (ch === undefined) {
|
||||
return err(new Error("invalid encoded string"));
|
||||
}
|
||||
const upper = ch.toUpperCase();
|
||||
const val = DECODE_MAP[upper];
|
||||
if (val === undefined) {
|
||||
return err(new Error(`invalid Crockford Base32 character: ${ch}`));
|
||||
}
|
||||
shifted = (shifted << 5n) | BigInt(val & 31);
|
||||
}
|
||||
return ok(shifted >> BigInt(padBits));
|
||||
}
|
||||
|
||||
/** XXH64-sized value (13 Crockford chars). */
|
||||
export function encodeUint64AsCrockford(value: bigint): string {
|
||||
const masked = value & 0xffff_ffff_ffff_ffffn;
|
||||
return encodeCrockfordBase32Bits(masked, 64);
|
||||
}
|
||||
|
||||
export function decodeCrockfordToUint64(encoded: string): Result<bigint, Error> {
|
||||
const decoded = decodeCrockfordBase32Bits(encoded, 64);
|
||||
if (!decoded.ok) {
|
||||
return decoded;
|
||||
}
|
||||
return ok(decoded.value & 0xffff_ffff_ffff_ffffn);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export {
|
||||
CROCKFORD_BASE32_ALPHABET,
|
||||
decodeCrockfordBase32Bits,
|
||||
decodeCrockfordToUint64,
|
||||
encodeCrockfordBase32Bits,
|
||||
encodeUint64AsCrockford,
|
||||
} from "./base32.js";
|
||||
export { createLogger } from "./logger.js";
|
||||
export { mergeRefsWithContentHash, normalizeRefsField } from "./refs-field.js";
|
||||
export { err, ok } from "./result.js";
|
||||
export { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
|
||||
export type { CreateLoggerOptions, LogFn, LoggerSink, Result } from "./types.js";
|
||||
export { generateUlid } from "./ulid.js";
|
||||
@@ -1,50 +0,0 @@
|
||||
import { appendFileSync } from "node:fs";
|
||||
|
||||
import { CROCKFORD_BASE32_ALPHABET } from "./base32.js";
|
||||
import type { CreateLoggerOptions, LogFn } from "./types.js";
|
||||
|
||||
const TAG_LENGTH = 8;
|
||||
|
||||
const TAG_CHAR_SET: ReadonlySet<string> = new Set(CROCKFORD_BASE32_ALPHABET.split(""));
|
||||
|
||||
function assertValidLogTag(tag: string): void {
|
||||
if (tag.length !== TAG_LENGTH) {
|
||||
throw new Error(`log tag must be exactly ${TAG_LENGTH} characters`);
|
||||
}
|
||||
for (let i = 0; i < tag.length; i++) {
|
||||
const ch = tag[i];
|
||||
if (ch === undefined) {
|
||||
throw new Error("log tag validation failed");
|
||||
}
|
||||
const upper = ch.toUpperCase();
|
||||
if (!TAG_CHAR_SET.has(upper)) {
|
||||
throw new Error(`invalid Crockford Base32 character in log tag: ${ch}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Append one JSONL log record: `{ tag, content, timestamp }` per RFC-001. */
|
||||
export function createLogger(options: CreateLoggerOptions): LogFn {
|
||||
if (options.sink.kind === "stderr") {
|
||||
return (tag: string, content: string) => {
|
||||
assertValidLogTag(tag);
|
||||
const line = `${JSON.stringify({
|
||||
tag: tag.toUpperCase(),
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
})}\n`;
|
||||
process.stderr.write(line);
|
||||
};
|
||||
}
|
||||
|
||||
const filePath = options.sink.path;
|
||||
return (tag: string, content: string) => {
|
||||
assertValidLogTag(tag);
|
||||
const line = `${JSON.stringify({
|
||||
tag: tag.toUpperCase(),
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
})}\n`;
|
||||
appendFileSync(filePath, line, "utf8");
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Normalize `refs` from persisted JSONL or IPC payloads (missing or invalid → []). */
|
||||
export function normalizeRefsField(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const x of value) {
|
||||
if (typeof x === "string") {
|
||||
out.push(x);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { err, ok } from "@uncaged/workflow-runtime";
|
||||
@@ -1,13 +0,0 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Default filesystem root for workflow data (`~/.uncaged/workflow`). */
|
||||
export function getDefaultWorkflowStorageRoot(): string {
|
||||
return join(homedir(), ".uncaged", "workflow");
|
||||
}
|
||||
|
||||
/** Global content-addressed store directory under the workflow storage root (`<root>/cas`). */
|
||||
export function getGlobalCasDir(storageRoot: string | undefined): string {
|
||||
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
|
||||
return join(root, "cas");
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export type { Result } from "@uncaged/workflow-runtime";
|
||||
|
||||
export type LoggerSink = { kind: "stderr" } | { kind: "file"; path: string };
|
||||
|
||||
export type CreateLoggerOptions = {
|
||||
sink: LoggerSink;
|
||||
};
|
||||
|
||||
export type LogFn = (tag: string, content: string) => void;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { encodeCrockfordBase32Bits } from "./base32.js";
|
||||
|
||||
const ULID_TIME_BITS = 48;
|
||||
const ULID_RANDOM_BITS = 80;
|
||||
|
||||
function readRandomUint80(): bigint {
|
||||
const bytes = new Uint8Array(10);
|
||||
crypto.getRandomValues(bytes);
|
||||
let x = 0n;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
x = (x << 8n) | BigInt(bytes[i]);
|
||||
}
|
||||
return x & ((1n << 80n) - 1n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a ULID using Crockford Base32: 10 timestamp chars + 16 random chars.
|
||||
* Timestamp uses 48 bits of Unix time in milliseconds.
|
||||
*/
|
||||
export function generateUlid(nowMs: number): string {
|
||||
if (!Number.isFinite(nowMs) || nowMs < 0 || nowMs >= 2 ** ULID_TIME_BITS) {
|
||||
throw new Error("nowMs must be a finite number in [0, 2^48)");
|
||||
}
|
||||
const time = BigInt(Math.floor(nowMs));
|
||||
const rand = readRandomUint80();
|
||||
const payload = (time << BigInt(ULID_RANDOM_BITS)) | rand;
|
||||
return encodeCrockfordBase32Bits(payload, ULID_TIME_BITS + ULID_RANDOM_BITS);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import type { AgentContext, AgentFn } from "@uncaged/workflow-runtime";
|
||||
import { extractBundleExports } from "./bundle/index.js";
|
||||
import { createCasStore } from "./cas/index.js";
|
||||
import type { ExecuteThreadIo } from "./engine/index.js";
|
||||
import { executeThread } from "./engine/index.js";
|
||||
import type { WorkflowConfig } from "./registry/index.js";
|
||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry/index.js";
|
||||
import {
|
||||
createLogger,
|
||||
generateUlid,
|
||||
getDefaultWorkflowStorageRoot,
|
||||
getGlobalCasDir,
|
||||
} from "./util/index.js";
|
||||
|
||||
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
|
||||
|
||||
function workflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
|
||||
if (config === null) {
|
||||
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
|
||||
}
|
||||
return config.maxDepth;
|
||||
}
|
||||
|
||||
export type WorkflowAsAgentOptions = {
|
||||
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
|
||||
storageRoot: string | null;
|
||||
};
|
||||
|
||||
function resolveWorkflowAsAgentStorageRoot(options: WorkflowAsAgentOptions | null): string {
|
||||
if (options !== null && options.storageRoot !== null) {
|
||||
return options.storageRoot;
|
||||
}
|
||||
return getDefaultWorkflowStorageRoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an {@link AgentFn} that runs another registered workflow in a new thread,
|
||||
* using the parent thread's initial prompt (`ctx.start.content`) as the child prompt.
|
||||
*/
|
||||
export function workflowAsAgent(
|
||||
workflowName: string,
|
||||
options: WorkflowAsAgentOptions | null = null,
|
||||
): AgentFn {
|
||||
return async (ctx: AgentContext): Promise<string> => {
|
||||
const nextDepth = ctx.depth + 1;
|
||||
|
||||
const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
|
||||
|
||||
const registryResult = await readWorkflowRegistry(storageRoot);
|
||||
if (!registryResult.ok) {
|
||||
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
|
||||
}
|
||||
|
||||
const maxDepth = workflowAsAgentMaxDepth(registryResult.value.config);
|
||||
if (nextDepth > maxDepth) {
|
||||
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
|
||||
}
|
||||
|
||||
const entry = getRegisteredWorkflow(registryResult.value, workflowName);
|
||||
if (entry === null) {
|
||||
return `ERROR: workflow "${workflowName}" not found in registry`;
|
||||
}
|
||||
|
||||
const bundlePath = join(storageRoot, "bundles", `${entry.hash}.esm.js`);
|
||||
const bundleExportsResult = await extractBundleExports(bundlePath, { storageRoot });
|
||||
if (!bundleExportsResult.ok) {
|
||||
return `ERROR: ${bundleExportsResult.error}`;
|
||||
}
|
||||
|
||||
const input = {
|
||||
prompt: ctx.start.content,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const childThreadId = generateUlid(Date.now());
|
||||
const dataJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.data.jsonl`);
|
||||
const infoJsonlPath = join(storageRoot, "logs", entry.hash, `${childThreadId}.info.jsonl`);
|
||||
|
||||
const io: ExecuteThreadIo = {
|
||||
threadId: childThreadId,
|
||||
hash: entry.hash,
|
||||
dataJsonlPath,
|
||||
infoJsonlPath,
|
||||
cas: createCasStore(getGlobalCasDir(storageRoot)),
|
||||
};
|
||||
|
||||
const logger = createLogger({ sink: { kind: "file", path: infoJsonlPath } });
|
||||
const signalNever = new AbortController();
|
||||
|
||||
try {
|
||||
const result = await executeThread(
|
||||
bundleExportsResult.value.run,
|
||||
workflowName,
|
||||
input,
|
||||
{
|
||||
maxRounds: ctx.start.meta.maxRounds,
|
||||
depth: nextDepth,
|
||||
signal: signalNever.signal,
|
||||
awaitAfterEachYield: async () => {},
|
||||
forkSourceThreadId: ctx.threadId,
|
||||
prefilledDiskSteps: null,
|
||||
storageRoot,
|
||||
},
|
||||
io,
|
||||
logger,
|
||||
);
|
||||
return result.rootHash;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return `ERROR: ${message}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"references": [{ "path": "../workflow-runtime" }],
|
||||
"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", "xxhashjs.d.ts"]
|
||||
}
|
||||
+6
-1
@@ -19,7 +19,12 @@
|
||||
},
|
||||
"references": [
|
||||
{ "path": "packages/workflow-runtime" },
|
||||
{ "path": "packages/workflow" },
|
||||
{ "path": "packages/workflow-protocol" },
|
||||
{ "path": "packages/workflow-util" },
|
||||
{ "path": "packages/workflow-cas" },
|
||||
{ "path": "packages/workflow-reactor" },
|
||||
{ "path": "packages/workflow-register" },
|
||||
{ "path": "packages/workflow-execute" },
|
||||
{ "path": "packages/workflow-agent-llm" },
|
||||
{ "path": "packages/workflow-agent-cursor" },
|
||||
{ "path": "packages/workflow-agent-hermes" },
|
||||
|
||||
Reference in New Issue
Block a user