From 727b4bb3edf379e89b79df60a337ae40d1752fb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sat, 9 May 2026 11:46:57 +0800 Subject: [PATCH] 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 --- packages/cli-workflow/tsconfig.json | 9 +- packages/workflow-agent-cursor/tsconfig.json | 2 +- packages/workflow-agent-hermes/tsconfig.json | 2 +- packages/workflow-agent-llm/package.json | 1 - packages/workflow-agent-llm/tsconfig.json | 2 +- packages/workflow-cas/package.json | 4 +- packages/workflow-execute/package.json | 8 +- packages/workflow-execute/tsconfig.json | 10 +- packages/workflow-runtime/tsconfig.json | 5 +- .../workflow-template-develop/package.json | 10 +- .../src/descriptor.ts | 2 +- .../workflow-template-develop/tsconfig.json | 5 +- .../package.json | 10 +- .../src/descriptor.ts | 2 +- .../tsconfig.json | 5 +- packages/workflow-util-agent/package.json | 1 - packages/workflow-util-agent/tsconfig.json | 2 +- packages/workflow-util/tsconfig.json | 5 +- packages/workflow/README.md | 36 - packages/workflow/__tests__/base32.test.ts | 38 - .../__tests__/build-descriptor.test.ts | 44 - .../__tests__/bundle-validator.test.ts | 138 ---- packages/workflow/__tests__/cas.test.ts | 104 --- packages/workflow/__tests__/engine.test.ts | 773 ------------------ .../workflow/__tests__/fork-thread.test.ts | 127 --- packages/workflow/__tests__/hash.test.ts | 25 - packages/workflow/__tests__/logger.test.ts | 31 - packages/workflow/__tests__/merkle.test.ts | 33 - .../workflow/__tests__/refs-tracking.test.ts | 182 ----- packages/workflow/__tests__/registry.test.ts | 259 ------ .../workflow/__tests__/resolve-model.test.ts | 104 --- packages/workflow/__tests__/result.test.ts | 21 - .../workflow/__tests__/storage-root.test.ts | 14 - .../workflow/__tests__/supervisor.test.ts | 160 ---- .../__tests__/thread-jsonl-format.test.ts | 44 - .../__tests__/thread-pause-gate.test.ts | 31 - .../workflow/__tests__/thread-reactor.test.ts | 259 ------ packages/workflow/__tests__/ulid.test.ts | 29 - packages/workflow/__tests__/worker.test.ts | 217 ----- .../workflow-as-agent-integration.test.ts | 204 ----- .../__tests__/workflow-as-agent.test.ts | 187 ----- .../__tests__/workflow-descriptor.test.ts | 196 ----- packages/workflow/package.json | 23 - .../workflow/src/bundle/build-descriptor.ts | 24 - .../workflow/src/bundle/bundle-import-env.ts | 8 - .../workflow/src/bundle/bundle-validator.ts | 422 ---------- .../bundle/ensure-uncaged-workflow-symlink.ts | 36 - .../src/bundle/extract-bundle-exports.ts | 42 - .../src/bundle/generate-descriptor.ts | 8 - packages/workflow/src/bundle/index.ts | 15 - packages/workflow/src/bundle/types.ts | 24 - .../src/bundle/workflow-descriptor.ts | 40 - packages/workflow/src/cas/cas.ts | 76 -- packages/workflow/src/cas/hash.ts | 24 - packages/workflow/src/cas/index.ts | 18 - packages/workflow/src/cas/merkle.ts | 99 --- packages/workflow/src/cas/types.ts | 23 - packages/workflow/src/config/index.ts | 3 - packages/workflow/src/config/resolve-model.ts | 30 - .../src/config/split-provider-model-ref.ts | 17 - packages/workflow/src/config/types.ts | 10 - .../workflow/src/engine/create-workflow.ts | 8 - packages/workflow/src/engine/engine.ts | 415 ---------- packages/workflow/src/engine/fork-thread.ts | 244 ------ packages/workflow/src/engine/gc.ts | 123 --- packages/workflow/src/engine/index.ts | 23 - packages/workflow/src/engine/supervisor.ts | 85 -- .../workflow/src/engine/thread-pause-gate.ts | 49 -- packages/workflow/src/engine/types.ts | 75 -- .../workflow/src/engine/worker-entry-path.ts | 6 - packages/workflow/src/engine/worker.ts | 488 ----------- packages/workflow/src/extract/extract-fn.ts | 136 --- packages/workflow/src/extract/index.ts | 11 - packages/workflow/src/extract/llm-extract.ts | 194 ----- packages/workflow/src/extract/types.ts | 18 - packages/workflow/src/index.ts | 109 --- packages/workflow/src/reactor/index.ts | 12 - packages/workflow/src/reactor/llm-fn.ts | 48 -- .../workflow/src/reactor/thread-reactor.ts | 317 ------- packages/workflow/src/reactor/types.ts | 62 -- packages/workflow/src/registry/index.ts | 18 - .../src/registry/registry-normalize.ts | 224 ----- packages/workflow/src/registry/registry.ts | 144 ---- packages/workflow/src/registry/types.ts | 25 - packages/workflow/src/util/base32.ts | 81 -- packages/workflow/src/util/index.ts | 13 - packages/workflow/src/util/logger.ts | 50 -- packages/workflow/src/util/refs-field.ts | 22 - packages/workflow/src/util/result.ts | 1 - packages/workflow/src/util/storage-root.ts | 13 - packages/workflow/src/util/types.ts | 9 - packages/workflow/src/util/ulid.ts | 28 - packages/workflow/src/workflow-as-agent.ts | 114 --- packages/workflow/tsconfig.json | 22 - tsconfig.json | 7 +- 95 files changed, 67 insertions(+), 7410 deletions(-) delete mode 100644 packages/workflow/README.md delete mode 100644 packages/workflow/__tests__/base32.test.ts delete mode 100644 packages/workflow/__tests__/build-descriptor.test.ts delete mode 100644 packages/workflow/__tests__/bundle-validator.test.ts delete mode 100644 packages/workflow/__tests__/cas.test.ts delete mode 100644 packages/workflow/__tests__/engine.test.ts delete mode 100644 packages/workflow/__tests__/fork-thread.test.ts delete mode 100644 packages/workflow/__tests__/hash.test.ts delete mode 100644 packages/workflow/__tests__/logger.test.ts delete mode 100644 packages/workflow/__tests__/merkle.test.ts delete mode 100644 packages/workflow/__tests__/refs-tracking.test.ts delete mode 100644 packages/workflow/__tests__/registry.test.ts delete mode 100644 packages/workflow/__tests__/resolve-model.test.ts delete mode 100644 packages/workflow/__tests__/result.test.ts delete mode 100644 packages/workflow/__tests__/storage-root.test.ts delete mode 100644 packages/workflow/__tests__/supervisor.test.ts delete mode 100644 packages/workflow/__tests__/thread-jsonl-format.test.ts delete mode 100644 packages/workflow/__tests__/thread-pause-gate.test.ts delete mode 100644 packages/workflow/__tests__/thread-reactor.test.ts delete mode 100644 packages/workflow/__tests__/ulid.test.ts delete mode 100644 packages/workflow/__tests__/worker.test.ts delete mode 100644 packages/workflow/__tests__/workflow-as-agent-integration.test.ts delete mode 100644 packages/workflow/__tests__/workflow-as-agent.test.ts delete mode 100644 packages/workflow/__tests__/workflow-descriptor.test.ts delete mode 100644 packages/workflow/package.json delete mode 100644 packages/workflow/src/bundle/build-descriptor.ts delete mode 100644 packages/workflow/src/bundle/bundle-import-env.ts delete mode 100644 packages/workflow/src/bundle/bundle-validator.ts delete mode 100644 packages/workflow/src/bundle/ensure-uncaged-workflow-symlink.ts delete mode 100644 packages/workflow/src/bundle/extract-bundle-exports.ts delete mode 100644 packages/workflow/src/bundle/generate-descriptor.ts delete mode 100644 packages/workflow/src/bundle/index.ts delete mode 100644 packages/workflow/src/bundle/types.ts delete mode 100644 packages/workflow/src/bundle/workflow-descriptor.ts delete mode 100644 packages/workflow/src/cas/cas.ts delete mode 100644 packages/workflow/src/cas/hash.ts delete mode 100644 packages/workflow/src/cas/index.ts delete mode 100644 packages/workflow/src/cas/merkle.ts delete mode 100644 packages/workflow/src/cas/types.ts delete mode 100644 packages/workflow/src/config/index.ts delete mode 100644 packages/workflow/src/config/resolve-model.ts delete mode 100644 packages/workflow/src/config/split-provider-model-ref.ts delete mode 100644 packages/workflow/src/config/types.ts delete mode 100644 packages/workflow/src/engine/create-workflow.ts delete mode 100644 packages/workflow/src/engine/engine.ts delete mode 100644 packages/workflow/src/engine/fork-thread.ts delete mode 100644 packages/workflow/src/engine/gc.ts delete mode 100644 packages/workflow/src/engine/index.ts delete mode 100644 packages/workflow/src/engine/supervisor.ts delete mode 100644 packages/workflow/src/engine/thread-pause-gate.ts delete mode 100644 packages/workflow/src/engine/types.ts delete mode 100644 packages/workflow/src/engine/worker-entry-path.ts delete mode 100644 packages/workflow/src/engine/worker.ts delete mode 100644 packages/workflow/src/extract/extract-fn.ts delete mode 100644 packages/workflow/src/extract/index.ts delete mode 100644 packages/workflow/src/extract/llm-extract.ts delete mode 100644 packages/workflow/src/extract/types.ts delete mode 100644 packages/workflow/src/index.ts delete mode 100644 packages/workflow/src/reactor/index.ts delete mode 100644 packages/workflow/src/reactor/llm-fn.ts delete mode 100644 packages/workflow/src/reactor/thread-reactor.ts delete mode 100644 packages/workflow/src/reactor/types.ts delete mode 100644 packages/workflow/src/registry/index.ts delete mode 100644 packages/workflow/src/registry/registry-normalize.ts delete mode 100644 packages/workflow/src/registry/registry.ts delete mode 100644 packages/workflow/src/registry/types.ts delete mode 100644 packages/workflow/src/util/base32.ts delete mode 100644 packages/workflow/src/util/index.ts delete mode 100644 packages/workflow/src/util/logger.ts delete mode 100644 packages/workflow/src/util/refs-field.ts delete mode 100644 packages/workflow/src/util/result.ts delete mode 100644 packages/workflow/src/util/storage-root.ts delete mode 100644 packages/workflow/src/util/types.ts delete mode 100644 packages/workflow/src/util/ulid.ts delete mode 100644 packages/workflow/src/workflow-as-agent.ts delete mode 100644 packages/workflow/tsconfig.json diff --git a/packages/cli-workflow/tsconfig.json b/packages/cli-workflow/tsconfig.json index 764b246..9ca68cf 100644 --- a/packages/cli-workflow/tsconfig.json +++ b/packages/cli-workflow/tsconfig.json @@ -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"] } diff --git a/packages/workflow-agent-cursor/tsconfig.json b/packages/workflow-agent-cursor/tsconfig.json index ed217ff..d9141ff 100644 --- a/packages/workflow-agent-cursor/tsconfig.json +++ b/packages/workflow-agent-cursor/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }] + "references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }] } diff --git a/packages/workflow-agent-hermes/tsconfig.json b/packages/workflow-agent-hermes/tsconfig.json index ed217ff..d9141ff 100644 --- a/packages/workflow-agent-hermes/tsconfig.json +++ b/packages/workflow-agent-hermes/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }, { "path": "../workflow-util-agent" }] + "references": [{ "path": "../workflow-runtime" }, { "path": "../workflow-util-agent" }] } diff --git a/packages/workflow-agent-llm/package.json b/packages/workflow-agent-llm/package.json index 3a791b1..f9162e8 100644 --- a/packages/workflow-agent-llm/package.json +++ b/packages/workflow-agent-llm/package.json @@ -8,7 +8,6 @@ "test": "bun test" }, "dependencies": { - "@uncaged/workflow": "workspace:*", "@uncaged/workflow-runtime": "workspace:*" } } diff --git a/packages/workflow-agent-llm/tsconfig.json b/packages/workflow-agent-llm/tsconfig.json index 2816fef..1187cda 100644 --- a/packages/workflow-agent-llm/tsconfig.json +++ b/packages/workflow-agent-llm/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }] + "references": [{ "path": "../workflow-runtime" }] } diff --git a/packages/workflow-cas/package.json b/packages/workflow-cas/package.json index f1ef5fa..158ebd3 100644 --- a/packages/workflow-cas/package.json +++ b/packages/workflow-cas/package.json @@ -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": { diff --git a/packages/workflow-execute/package.json b/packages/workflow-execute/package.json index 90d28fe..026e574 100644 --- a/packages/workflow-execute/package.json +++ b/packages/workflow-execute/package.json @@ -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" }, diff --git a/packages/workflow-execute/tsconfig.json b/packages/workflow-execute/tsconfig.json index 75eba9f..2b204f7 100644 --- a/packages/workflow-execute/tsconfig.json +++ b/packages/workflow-execute/tsconfig.json @@ -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" } + ] } diff --git a/packages/workflow-runtime/tsconfig.json b/packages/workflow-runtime/tsconfig.json index cfef04b..bd7eaf3 100644 --- a/packages/workflow-runtime/tsconfig.json +++ b/packages/workflow-runtime/tsconfig.json @@ -17,5 +17,8 @@ "rootDir": "src", "types": ["bun-types"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [ + { "path": "../workflow-protocol" } + ] } diff --git a/packages/workflow-template-develop/package.json b/packages/workflow-template-develop/package.json index 19e9d46..46509c1 100644 --- a/packages/workflow-template-develop/package.json +++ b/packages/workflow-template-develop/package.json @@ -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" } diff --git a/packages/workflow-template-develop/src/descriptor.ts b/packages/workflow-template-develop/src/descriptor.ts index 1dc4057..f5683c6 100644 --- a/packages/workflow-template-develop/src/descriptor.ts +++ b/packages/workflow-template-develop/src/descriptor.ts @@ -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"; diff --git a/packages/workflow-template-develop/tsconfig.json b/packages/workflow-template-develop/tsconfig.json index 2816fef..af22af0 100644 --- a/packages/workflow-template-develop/tsconfig.json +++ b/packages/workflow-template-develop/tsconfig.json @@ -6,5 +6,8 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }] + "references": [ + { "path": "../workflow-register" }, + { "path": "../workflow-runtime" } + ] } diff --git a/packages/workflow-template-solve-issue/package.json b/packages/workflow-template-solve-issue/package.json index dd9d02e..1b58e31 100644 --- a/packages/workflow-template-solve-issue/package.json +++ b/packages/workflow-template-solve-issue/package.json @@ -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" } diff --git a/packages/workflow-template-solve-issue/src/descriptor.ts b/packages/workflow-template-solve-issue/src/descriptor.ts index 21d1832..44a6999 100644 --- a/packages/workflow-template-solve-issue/src/descriptor.ts +++ b/packages/workflow-template-solve-issue/src/descriptor.ts @@ -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"; diff --git a/packages/workflow-template-solve-issue/tsconfig.json b/packages/workflow-template-solve-issue/tsconfig.json index 2816fef..af22af0 100644 --- a/packages/workflow-template-solve-issue/tsconfig.json +++ b/packages/workflow-template-solve-issue/tsconfig.json @@ -6,5 +6,8 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }] + "references": [ + { "path": "../workflow-register" }, + { "path": "../workflow-runtime" } + ] } diff --git a/packages/workflow-util-agent/package.json b/packages/workflow-util-agent/package.json index 0f7e239..c9904cc 100644 --- a/packages/workflow-util-agent/package.json +++ b/packages/workflow-util-agent/package.json @@ -14,7 +14,6 @@ "test": "bun test" }, "dependencies": { - "@uncaged/workflow": "workspace:*", "@uncaged/workflow-runtime": "workspace:*" } } diff --git a/packages/workflow-util-agent/tsconfig.json b/packages/workflow-util-agent/tsconfig.json index 2816fef..1187cda 100644 --- a/packages/workflow-util-agent/tsconfig.json +++ b/packages/workflow-util-agent/tsconfig.json @@ -6,5 +6,5 @@ "composite": true }, "include": ["src/**/*.ts"], - "references": [{ "path": "../workflow" }] + "references": [{ "path": "../workflow-runtime" }] } diff --git a/packages/workflow-util/tsconfig.json b/packages/workflow-util/tsconfig.json index 75eba9f..778d958 100644 --- a/packages/workflow-util/tsconfig.json +++ b/packages/workflow-util/tsconfig.json @@ -4,5 +4,8 @@ "rootDir": "src", "outDir": "dist" }, - "include": ["src"] + "include": ["src"], + "references": [ + { "path": "../workflow-protocol" } + ] } diff --git a/packages/workflow/README.md b/packages/workflow/README.md deleted file mode 100644 index db319e3..0000000 --- a/packages/workflow/README.md +++ /dev/null @@ -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`. diff --git a/packages/workflow/__tests__/base32.test.ts b/packages/workflow/__tests__/base32.test.ts deleted file mode 100644 index 88f09d5..0000000 --- a/packages/workflow/__tests__/base32.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/workflow/__tests__/build-descriptor.test.ts b/packages/workflow/__tests__/build-descriptor.test.ts deleted file mode 100644 index e60884e..0000000 --- a/packages/workflow/__tests__/build-descriptor.test.ts +++ /dev/null @@ -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 }; - - const descriptor = buildDescriptor({ - 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; - expect(props.title).toMatchObject({ type: "string" }); - expect(props.count).toMatchObject({ type: "number" }); - }); -}); diff --git a/packages/workflow/__tests__/bundle-validator.test.ts b/packages/workflow/__tests__/bundle-validator.test.ts deleted file mode 100644 index ad7af01..0000000 --- a/packages/workflow/__tests__/bundle-validator.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/workflow/__tests__/cas.test.ts b/packages/workflow/__tests__/cas.test.ts deleted file mode 100644 index f04ecb6..0000000 --- a/packages/workflow/__tests__/cas.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/workflow/__tests__/engine.test.ts b/packages/workflow/__tests__/engine.test.ts deleted file mode 100644 index cb2d92f..0000000 --- a/packages/workflow/__tests__/engine.test.ts +++ /dev/null @@ -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; - coder: z.infer; -}; - -function installMockChatCompletions(sequence: ReadonlyArray>): () => void { - const origFetch = globalThis.fetch; - let i = 0; - const mockFetch = async ( - _input: Parameters[0], - _init?: RequestInit, - ): Promise => { - 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 { - 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 { - 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>; - supervisorDecision: "continue" | "stop"; - onSupervisorCall?: () => void; -}): () => void { - const origFetch = globalThis.fetch; - let extractI = 0; - const mockFetch = async ( - _input: Parameters[0], - init?: RequestInit, - ): Promise => { - const body = init?.body ? (JSON.parse(String(init.body)) as Record) : {}; - 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( - { - 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; - expect(rootPayload.workflow).toBe("demo-flow"); - expect(rootPayload.threadId).toBe(threadId); - const rootResult = rootPayload.result as Record; - 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; - 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; - expect(params.prompt).toBe("Fix the login redirect bug in #3"); - const opts = params.options as Record; - 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; - 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; - 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; - 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; - 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; - expect(start.forkFrom).toEqual({ threadId: "01SRC1111111111111111111" }); - - const role0 = JSON.parse(lines[1] ?? "{}") as Record; - expect(role0.role).toBe("planner"); - expect(role0.timestamp).toBe(histTs); - expect(role0.refs).toEqual(mergedPlannerRefs); - - const role1 = JSON.parse(lines[2] ?? "{}") as Record; - 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; - const roleCoder = JSON.parse(lines[2] ?? "{}") as Record; - - 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 }; - - 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[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( - { - 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; - 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 }); - } - }); -}); diff --git a/packages/workflow/__tests__/fork-thread.test.ts b/packages/workflow/__tests__/fork-thread.test.ts deleted file mode 100644 index 27bd253..0000000 --- a/packages/workflow/__tests__/fork-thread.test.ts +++ /dev/null @@ -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 }); - }); -}); diff --git a/packages/workflow/__tests__/hash.test.ts b/packages/workflow/__tests__/hash.test.ts deleted file mode 100644 index b8b345b..0000000 --- a/packages/workflow/__tests__/hash.test.ts +++ /dev/null @@ -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)); - }); -}); diff --git a/packages/workflow/__tests__/logger.test.ts b/packages/workflow/__tests__/logger.test.ts deleted file mode 100644 index d6c39b9..0000000 --- a/packages/workflow/__tests__/logger.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/workflow/__tests__/merkle.test.ts b/packages/workflow/__tests__/merkle.test.ts deleted file mode 100644 index 3598cd7..0000000 --- a/packages/workflow/__tests__/merkle.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/workflow/__tests__/refs-tracking.test.ts b/packages/workflow/__tests__/refs-tracking.test.ts deleted file mode 100644 index 075689d..0000000 --- a/packages/workflow/__tests__/refs-tracking.test.ts +++ /dev/null @@ -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; -}; - -function installMockChatCompletions(sequence: ReadonlyArray>): () => void { - const origFetch = globalThis.fetch; - let i = 0; - const mockFetch = async ( - _input: Parameters[0], - _init?: RequestInit, - ): Promise => { - 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( - { - 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; - 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"]); - }); -}); diff --git a/packages/workflow/__tests__/registry.test.ts b/packages/workflow/__tests__/registry.test.ts deleted file mode 100644 index 20a7642..0000000 --- a/packages/workflow/__tests__/registry.test.ts +++ /dev/null @@ -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 }); - }); -}); diff --git a/packages/workflow/__tests__/resolve-model.test.ts b/packages/workflow/__tests__/resolve-model.test.ts deleted file mode 100644 index 7612cc0..0000000 --- a/packages/workflow/__tests__/resolve-model.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/workflow/__tests__/result.test.ts b/packages/workflow/__tests__/result.test.ts deleted file mode 100644 index 14f3c4b..0000000 --- a/packages/workflow/__tests__/result.test.ts +++ /dev/null @@ -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"); - } - }); -}); diff --git a/packages/workflow/__tests__/storage-root.test.ts b/packages/workflow/__tests__/storage-root.test.ts deleted file mode 100644 index 9a3ca2d..0000000 --- a/packages/workflow/__tests__/storage-root.test.ts +++ /dev/null @@ -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")); - }); -}); diff --git a/packages/workflow/__tests__/supervisor.test.ts b/packages/workflow/__tests__/supervisor.test.ts deleted file mode 100644 index 3591ae8..0000000 --- a/packages/workflow/__tests__/supervisor.test.ts +++ /dev/null @@ -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, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function installFetchMock(impl: (init?: RequestInit) => Promise): () => void { - const origFetch = globalThis.fetch; - globalThis.fetch = Object.assign( - async (_input: Parameters[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); - }); -}); diff --git a/packages/workflow/__tests__/thread-jsonl-format.test.ts b/packages/workflow/__tests__/thread-jsonl-format.test.ts deleted file mode 100644 index 92fea30..0000000 --- a/packages/workflow/__tests__/thread-jsonl-format.test.ts +++ /dev/null @@ -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()); - }); -}); diff --git a/packages/workflow/__tests__/thread-pause-gate.test.ts b/packages/workflow/__tests__/thread-pause-gate.test.ts deleted file mode 100644 index 9b568e0..0000000 --- a/packages/workflow/__tests__/thread-pause-gate.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/workflow/__tests__/thread-reactor.test.ts b/packages/workflow/__tests__/thread-reactor.test.ts deleted file mode 100644 index c880df1..0000000 --- a/packages/workflow/__tests__/thread-reactor.test.ts +++ /dev/null @@ -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 }; - -function createTestReactor() { - const llm = createLlmFn(provider); - return createThreadReactor({ - 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[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[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[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 }); - } - }); -}); diff --git a/packages/workflow/__tests__/ulid.test.ts b/packages/workflow/__tests__/ulid.test.ts deleted file mode 100644 index 7608cc3..0000000 --- a/packages/workflow/__tests__/ulid.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/workflow/__tests__/worker.test.ts b/packages/workflow/__tests__/worker.test.ts deleted file mode 100644 index 07b353b..0000000 --- a/packages/workflow/__tests__/worker.test.ts +++ /dev/null @@ -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 { - 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 { - await new Promise((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; - expect(start.forkFrom).toEqual({ threadId: srcId }); - const replay = JSON.parse(lines[1] ?? "{}") as Record; - expect(replay.role).toBe("planner"); - expect(replay.timestamp).toBe(555); - const coder = JSON.parse(lines[2] ?? "{}") as Record; - expect(coder.role).toBe("coder"); - const done = JSON.parse(lines[3] ?? "{}") as Record; - expect(done.returnCode).toBe(0); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, 15_000); -}); diff --git a/packages/workflow/__tests__/workflow-as-agent-integration.test.ts b/packages/workflow/__tests__/workflow-as-agent-integration.test.ts deleted file mode 100644 index bb690a8..0000000 --- a/packages/workflow/__tests__/workflow-as-agent-integration.test.ts +++ /dev/null @@ -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; -}; - -function installMockChatCompletions(sequence: ReadonlyArray>): () => void { - const origFetch = globalThis.fetch; - let i = 0; - const mockFetch = async ( - _input: Parameters[0], - _init?: RequestInit, - ): Promise => { - 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( - { - 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; - 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; - expect(childPayload.workflow).toBe("child-wf"); - const childResult = childPayload.result as Record; - 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; - expect(childStart.forkFrom).toEqual({ threadId }); - const childOpts = (childStart.parameters as Record).options as Record< - string, - unknown - >; - expect(childOpts.depth).toBe(1); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/workflow/__tests__/workflow-as-agent.test.ts b/packages/workflow/__tests__/workflow-as-agent.test.ts deleted file mode 100644 index 18219e1..0000000 --- a/packages/workflow/__tests__/workflow-as-agent.test.ts +++ /dev/null @@ -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; - expect(payload.workflow).toBe("child-wf"); - const resultObj = payload.result as Record; - 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 }); - } - }); -}); diff --git a/packages/workflow/__tests__/workflow-descriptor.test.ts b/packages/workflow/__tests__/workflow-descriptor.test.ts deleted file mode 100644 index fd4708d..0000000 --- a/packages/workflow/__tests__/workflow-descriptor.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/packages/workflow/package.json b/packages/workflow/package.json deleted file mode 100644 index 3cce1c0..0000000 --- a/packages/workflow/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/workflow/src/bundle/build-descriptor.ts b/packages/workflow/src/bundle/build-descriptor.ts deleted file mode 100644 index b81fd7c..0000000 --- a/packages/workflow/src/bundle/build-descriptor.ts +++ /dev/null @@ -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): WorkflowRoleSchema { - const { $schema: _drop, ...rest } = json; - return rest as WorkflowRoleSchema; -} - -export function buildDescriptor( - def: WorkflowDefinition, -): 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; - roles[key] = { - description: roleDef.description, - schema: stripJsonSchemaMeta(rawJsonSchema), - }; - } - return { description: def.description, roles }; -} diff --git a/packages/workflow/src/bundle/bundle-import-env.ts b/packages/workflow/src/bundle/bundle-import-env.ts deleted file mode 100644 index ef1bf1c..0000000 --- a/packages/workflow/src/bundle/bundle-import-env.ts +++ /dev/null @@ -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 { - return import(pathToFileURL(bundlePath).href); -} diff --git a/packages/workflow/src/bundle/bundle-validator.ts b/packages/workflow/src/bundle/bundle-validator.ts deleted file mode 100644 index b140145..0000000 --- a/packages/workflow/src/bundle/bundle-validator.ts +++ /dev/null @@ -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(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(node)); - } - if (node.type === "ExportNamedDeclaration") { - return validateExportNamedDeclaration(narrowNode(node)); - } - if (node.type === "ExportAllDeclaration") { - return validateExportAllDeclaration(narrowNode(node)); - } - if (node.type === "CallExpression") { - return validateRequireCall(narrowNode(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 { - 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); -} diff --git a/packages/workflow/src/bundle/ensure-uncaged-workflow-symlink.ts b/packages/workflow/src/bundle/ensure-uncaged-workflow-symlink.ts deleted file mode 100644 index 247444b..0000000 --- a/packages/workflow/src/bundle/ensure-uncaged-workflow-symlink.ts +++ /dev/null @@ -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 `/node_modules/@uncaged/workflow` points at the installed `@uncaged/workflow` - * package so workflow bundles loaded from `/bundles/*.esm.js` can resolve `import "@uncaged/workflow"`. - */ -export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise { - 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); -} diff --git a/packages/workflow/src/bundle/extract-bundle-exports.ts b/packages/workflow/src/bundle/extract-bundle-exports.ts deleted file mode 100644 index 6184b08..0000000 --- a/packages/workflow/src/bundle/extract-bundle-exports.ts +++ /dev/null @@ -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> { - 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; - 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 }); -} diff --git a/packages/workflow/src/bundle/generate-descriptor.ts b/packages/workflow/src/bundle/generate-descriptor.ts deleted file mode 100644 index 396586a..0000000 --- a/packages/workflow/src/bundle/generate-descriptor.ts +++ /dev/null @@ -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" }); -} diff --git a/packages/workflow/src/bundle/index.ts b/packages/workflow/src/bundle/index.ts deleted file mode 100644 index f9f7e0e..0000000 --- a/packages/workflow/src/bundle/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/bundle/types.ts b/packages/workflow/src/bundle/types.ts deleted file mode 100644 index 5776cdf..0000000 --- a/packages/workflow/src/bundle/types.ts +++ /dev/null @@ -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; -}; diff --git a/packages/workflow/src/bundle/workflow-descriptor.ts b/packages/workflow/src/bundle/workflow-descriptor.ts deleted file mode 100644 index e21851b..0000000 --- a/packages/workflow/src/bundle/workflow-descriptor.ts +++ /dev/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 { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return err("descriptor must be a non-array object"); - } - const root = value as Record; - 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 = {}; - 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; - 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 }); -} diff --git a/packages/workflow/src/cas/cas.ts b/packages/workflow/src/cas/cas.ts deleted file mode 100644 index b6a1cce..0000000 --- a/packages/workflow/src/cas/cas.ts +++ /dev/null @@ -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 { - await mkdir(casDir, { recursive: true }); - } - - function filePath(hash: string): string { - return join(casDir, `${hash}.txt`); - } - - return { - async put(content: string): Promise { - 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 { - 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 { - try { - await unlink(filePath(hash)); - } catch (e) { - const errObj = e as NodeJS.ErrnoException; - if (errObj.code === "ENOENT") { - return; - } - throw e; - } - }, - - async list(): Promise { - 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; - } - }, - }; -} diff --git a/packages/workflow/src/cas/hash.ts b/packages/workflow/src/cas/hash.ts deleted file mode 100644 index edf7207..0000000 --- a/packages/workflow/src/cas/hash.ts +++ /dev/null @@ -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)); -} diff --git a/packages/workflow/src/cas/index.ts b/packages/workflow/src/cas/index.ts deleted file mode 100644 index 68aa997..0000000 --- a/packages/workflow/src/cas/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/cas/merkle.ts b/packages/workflow/src/cas/merkle.ts deleted file mode 100644 index 178f51e..0000000 --- a/packages/workflow/src/cas/merkle.ts +++ /dev/null @@ -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; - 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), - 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 { - 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 { - 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 { - 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 { - 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; -} diff --git a/packages/workflow/src/cas/types.ts b/packages/workflow/src/cas/types.ts deleted file mode 100644 index 35e150a..0000000 --- a/packages/workflow/src/cas/types.ts +++ /dev/null @@ -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; - children: string[]; -}; - -export type StepMerklePayload = { - role: string; - meta: Record; -}; - -export type ThreadMerklePayload = { - workflow: string; - threadId: string; - result: { - returnCode: number; - summary: string; - }; -}; diff --git a/packages/workflow/src/config/index.ts b/packages/workflow/src/config/index.ts deleted file mode 100644 index 029a409..0000000 --- a/packages/workflow/src/config/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/config/resolve-model.ts b/packages/workflow/src/config/resolve-model.ts deleted file mode 100644 index e766b44..0000000 --- a/packages/workflow/src/config/resolve-model.ts +++ /dev/null @@ -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 { - 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, - }); -} diff --git a/packages/workflow/src/config/split-provider-model-ref.ts b/packages/workflow/src/config/split-provider-model-ref.ts deleted file mode 100644 index 0002920..0000000 --- a/packages/workflow/src/config/split-provider-model-ref.ts +++ /dev/null @@ -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 }); -} diff --git a/packages/workflow/src/config/types.ts b/packages/workflow/src/config/types.ts deleted file mode 100644 index b65d1e8..0000000 --- a/packages/workflow/src/config/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type ProviderConfig = { - baseUrl: string; - apiKey: string; -}; - -export type ResolvedModel = { - baseUrl: string; - apiKey: string; - model: string; -}; diff --git a/packages/workflow/src/engine/create-workflow.ts b/packages/workflow/src/engine/create-workflow.ts deleted file mode 100644 index b7fc2a7..0000000 --- a/packages/workflow/src/engine/create-workflow.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/engine/engine.ts b/packages/workflow/src/engine/engine.ts deleted file mode 100644 index 0cf4154..0000000 --- a/packages/workflow/src/engine/engine.ts +++ /dev/null @@ -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; - 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 { - 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 { - 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 { - 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 { - 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 { - 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((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 { - 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 = { - 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, - }); -} diff --git a/packages/workflow/src/engine/fork-thread.ts b/packages/workflow/src/engine/fork-thread.ts deleted file mode 100644 index 8b52b24..0000000 --- a/packages/workflow/src/engine/fork-thread.ts +++ /dev/null @@ -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, -): 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): 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, - refs: normalizeRefsField(obj.refs), - timestamp, - }; -} - -function parseRoleLine( - obj: Record, - lineIndex: number, -): Result { - const parsed = tryParseRoleStepRecord(obj); - if (parsed === null) { - return err(`invalid role record at line ${lineIndex}`); - } - return ok(parsed); -} - -function parseStartRecordLine(firstLine: string): Result { - 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; - 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; - 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; - 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 { - 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; - 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(); - 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 { - 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 { - 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, - }); -} diff --git a/packages/workflow/src/engine/gc.ts b/packages/workflow/src/engine/gc.ts deleted file mode 100644 index f566688..0000000 --- a/packages/workflow/src/engine/gc.ts +++ /dev/null @@ -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> { - 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, string>> { - const activeRefs = new Set(); - 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, -): Promise> { - 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> { - 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, - }); -} diff --git a/packages/workflow/src/engine/index.ts b/packages/workflow/src/engine/index.ts deleted file mode 100644 index f7b6d74..0000000 --- a/packages/workflow/src/engine/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/engine/supervisor.ts b/packages/workflow/src/engine/supervisor.ts deleted file mode 100644 index cef8777..0000000 --- a/packages/workflow/src/engine/supervisor.ts +++ /dev/null @@ -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; - -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> { - const resolved = resolveModel(args.config, "supervisor"); - if (!resolved.ok) { - return ok("continue"); - } - - const reactor = createThreadReactor({ - 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); -} diff --git a/packages/workflow/src/engine/thread-pause-gate.ts b/packages/workflow/src/engine/thread-pause-gate.ts deleted file mode 100644 index 6a14aef..0000000 --- a/packages/workflow/src/engine/thread-pause-gate.ts +++ /dev/null @@ -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 = Promise.resolve(); - let paused = false; - - function awaitAfterYield(): Promise { - return chain; - } - - function pause(): Result { - if (paused) { - return err("thread already paused"); - } - paused = true; - chain = new Promise((resolve) => { - resumeResolver = resolve; - }); - return ok(undefined); - } - - function resume(): Result { - 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 }; -} diff --git a/packages/workflow/src/engine/types.ts b/packages/workflow/src/engine/types.ts deleted file mode 100644 index 8788096..0000000 --- a/packages/workflow/src/engine/types.ts +++ /dev/null @@ -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; - 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; - /** 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; - pause: () => Result; - resume: () => Result; - isPaused: () => boolean; -}; diff --git a/packages/workflow/src/engine/worker-entry-path.ts b/packages/workflow/src/engine/worker-entry-path.ts deleted file mode 100644 index 5690bd2..0000000 --- a/packages/workflow/src/engine/worker-entry-path.ts +++ /dev/null @@ -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)); -} diff --git a/packages/workflow/src/engine/worker.ts b/packages/workflow/src/engine/worker.ts deleted file mode 100644 index e83ab7d..0000000 --- a/packages/workflow/src/engine/worker.ts +++ /dev/null @@ -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): 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, - refs: normalizeRefsField(obj.refs), - }; -} - -function parseRunStepsPayload(rec: Record): { - 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; - 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): 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; - 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, -): 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; - 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 { - 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, - 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 { - 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 { - 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 "); - 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; - 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(); - let activeThreads = 0; - let shutdownTimer: ReturnType | 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 { - 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((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 main(); diff --git a/packages/workflow/src/extract/extract-fn.ts b/packages/workflow/src/extract/extract-fn.ts deleted file mode 100644 index b278562..0000000 --- a/packages/workflow/src/extract/extract-fn.ts +++ /dev/null @@ -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 { - 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 { - 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({ - 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": ""}.'; - } - const blob = await thread.cas.get(hash); - return blob === null ? "null" : blob; - }, - }); - - return async >( - schema: z.ZodType, - prompt: string, - ctx: ExtractContext, - ): Promise => { - 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; - }; -} diff --git a/packages/workflow/src/extract/index.ts b/packages/workflow/src/extract/index.ts deleted file mode 100644 index e1069af..0000000 --- a/packages/workflow/src/extract/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/extract/llm-extract.ts b/packages/workflow/src/extract/llm-extract.ts deleted file mode 100644 index 0e2dc50..0000000 --- a/packages/workflow/src/extract/llm-extract.ts +++ /dev/null @@ -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 { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function stripJsonSchemaMeta(json: Record): Record { - const { $schema: _drop, ...rest } = json; - return rest; -} - -function readToolName(parametersSchema: Record): string { - const title = parametersSchema.title; - if (typeof title === "string" && title.trim().length > 0) { - return title.trim(); - } - return "extract"; -} - -function readToolDescription(parametersSchema: Record): 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): { - name: string; - description: string; - parameters: Record; -} { - const rawJsonSchema = z.toJSONSchema(schema) as Record; - const parameters = stripJsonSchemaMeta(rawJsonSchema); - return { - name: readToolName(parameters), - description: readToolDescription(parameters), - parameters, - }; -} - -function readToolArgumentsJson(parsed: unknown, previewSource: string): Result { - 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( - options: LlmExtractArgs & { userContent: string }, -): Promise> { - 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(options: LlmExtractArgs): Promise> { - return performLlmExtract({ ...options, userContent: options.text }); -} diff --git a/packages/workflow/src/extract/types.ts b/packages/workflow/src/extract/types.ts deleted file mode 100644 index c5bf283..0000000 --- a/packages/workflow/src/extract/types.ts +++ /dev/null @@ -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 = { - text: string; - schema: z.ZodType; - 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 }; diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts deleted file mode 100644 index 896f865..0000000 --- a/packages/workflow/src/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/reactor/index.ts b/packages/workflow/src/reactor/index.ts deleted file mode 100644 index 80f2ab2..0000000 --- a/packages/workflow/src/reactor/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/reactor/llm-fn.ts b/packages/workflow/src/reactor/llm-fn.ts deleted file mode 100644 index fd5c911..0000000 --- a/packages/workflow/src/reactor/llm-fn.ts +++ /dev/null @@ -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}`); - } - }; -} diff --git a/packages/workflow/src/reactor/thread-reactor.ts b/packages/workflow/src/reactor/thread-reactor.ts deleted file mode 100644 index 4931bd4..0000000 --- a/packages/workflow/src/reactor/thread-reactor.ts +++ /dev/null @@ -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 { - 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, 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 { - 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 = - | { kind: "plain_json"; value: T } - | { kind: "tool_calls"; calls: ToolCall[]; assistantContent: string | null }; - -type AssistantTurnOrCorrection = - | AssistantTurn - | { kind: "plain_json_invalid"; rawContent: string; correction: string }; - -function classifyAssistantTurn( - messageObj: Record, - schema: z.ZodType, - structuredToolName: string, -): Result, 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 { - return new Set(tools.map((t) => t.function.name)); -} - -function appendStructuredToolResult( - tc: ToolCall, - schema: z.ZodType, - 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( - tc: ToolCall, - spec: StructuredToolSpec, - knownNames: Set, - schema: z.ZodType, - thread: TThread, - toolHandler: ThreadReactorConfig["toolHandler"], - messages: ChatMessage[], -): Promise { - 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( - turn: Extract, { kind: "tool_calls" }>, - spec: StructuredToolSpec, - knownNames: Set, - schema: z.ZodType, - thread: TThread, - toolHandler: ThreadReactorConfig["toolHandler"], - messages: ChatMessage[], -): Promise | 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( - config: ThreadReactorConfig, - args: { thread: TThread; schema: z.ZodType }, - tools: readonly ToolDefinition[], - knownNames: Set, - spec: StructuredToolSpec, - messages: ChatMessage[], -): Promise | 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( - config: ThreadReactorConfig, -): ThreadReactorFn { - return async (args: { - thread: TThread; - input: string; - schema: z.ZodType; - }): Promise> => { - 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"); - }; -} diff --git a/packages/workflow/src/reactor/types.ts b/packages/workflow/src/reactor/types.ts deleted file mode 100644 index 5d9d499..0000000 --- a/packages/workflow/src/reactor/types.ts +++ /dev/null @@ -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; - }; -}; - -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>; - -/** Structured tool derived from the per-invocation Zod schema (e.g. extract tool). */ -export type StructuredToolSpec = { - name: string; - tool: ToolDefinition; -}; - -export type ThreadReactorConfig = { - 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) => 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; - maxRounds: number; -}; - -export type ThreadReactorInvokeArgs = { - thread: TThread; - input: string; - schema: z.ZodType; -}; - -export type ThreadReactorFn = ( - args: ThreadReactorInvokeArgs, -) => Promise>; diff --git a/packages/workflow/src/registry/index.ts b/packages/workflow/src/registry/index.ts deleted file mode 100644 index e200664..0000000 --- a/packages/workflow/src/registry/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/registry/registry-normalize.ts b/packages/workflow/src/registry/registry-normalize.ts deleted file mode 100644 index 5f493fc..0000000 --- a/packages/workflow/src/registry/registry-normalize.ts +++ /dev/null @@ -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 { - 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 { - 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; - 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, 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; - const providers: Record = {}; - 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, -): Result, 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; - const models: Record = {}; - 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 { - if (raw === null || typeof raw !== "object") { - return err(new Error('registry "config" must be a mapping')); - } - const c = raw as Record; - 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 { - if (raw === null || typeof raw !== "object") { - return err(new Error(`workflow "${workflowName}" history[${index}] must be a mapping`)); - } - const he = raw as Record; - 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 { - if (raw === null || typeof raw !== "object") { - return err(new Error(`workflow "${workflowName}" must be a mapping`)); - } - const e = raw as Record; - 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 { - if (raw === null || typeof raw !== "object") { - return err(new Error("registry root must be a mapping")); - } - const root = raw as Record; - 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 = {}; - 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 }); -} diff --git a/packages/workflow/src/registry/registry.ts b/packages/workflow/src/registry/registry.ts deleted file mode 100644 index 947bc4f..0000000 --- a/packages/workflow/src/registry/registry.ts +++ /dev/null @@ -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 { - 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> { - 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> { - 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 { - 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 { - 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 }); -} diff --git a/packages/workflow/src/registry/types.ts b/packages/workflow/src/registry/types.ts deleted file mode 100644 index 8cba10f..0000000 --- a/packages/workflow/src/registry/types.ts +++ /dev/null @@ -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; - models: Record; -}; - -export type WorkflowRegistryFile = { - config: WorkflowConfig | null; - workflows: Record; -}; diff --git a/packages/workflow/src/util/base32.ts b/packages/workflow/src/util/base32.ts deleted file mode 100644 index 330fa14..0000000 --- a/packages/workflow/src/util/base32.ts +++ /dev/null @@ -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 = (() => { - const map: Record = {}; - 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 { - 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 { - const decoded = decodeCrockfordBase32Bits(encoded, 64); - if (!decoded.ok) { - return decoded; - } - return ok(decoded.value & 0xffff_ffff_ffff_ffffn); -} diff --git a/packages/workflow/src/util/index.ts b/packages/workflow/src/util/index.ts deleted file mode 100644 index 85ee59b..0000000 --- a/packages/workflow/src/util/index.ts +++ /dev/null @@ -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"; diff --git a/packages/workflow/src/util/logger.ts b/packages/workflow/src/util/logger.ts deleted file mode 100644 index 2305def..0000000 --- a/packages/workflow/src/util/logger.ts +++ /dev/null @@ -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 = 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"); - }; -} diff --git a/packages/workflow/src/util/refs-field.ts b/packages/workflow/src/util/refs-field.ts deleted file mode 100644 index e89d40c..0000000 --- a/packages/workflow/src/util/refs-field.ts +++ /dev/null @@ -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; -} diff --git a/packages/workflow/src/util/result.ts b/packages/workflow/src/util/result.ts deleted file mode 100644 index c6cede3..0000000 --- a/packages/workflow/src/util/result.ts +++ /dev/null @@ -1 +0,0 @@ -export { err, ok } from "@uncaged/workflow-runtime"; diff --git a/packages/workflow/src/util/storage-root.ts b/packages/workflow/src/util/storage-root.ts deleted file mode 100644 index f270645..0000000 --- a/packages/workflow/src/util/storage-root.ts +++ /dev/null @@ -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 (`/cas`). */ -export function getGlobalCasDir(storageRoot: string | undefined): string { - const root = storageRoot ?? getDefaultWorkflowStorageRoot(); - return join(root, "cas"); -} diff --git a/packages/workflow/src/util/types.ts b/packages/workflow/src/util/types.ts deleted file mode 100644 index 800c37f..0000000 --- a/packages/workflow/src/util/types.ts +++ /dev/null @@ -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; diff --git a/packages/workflow/src/util/ulid.ts b/packages/workflow/src/util/ulid.ts deleted file mode 100644 index 60938cf..0000000 --- a/packages/workflow/src/util/ulid.ts +++ /dev/null @@ -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); -} diff --git a/packages/workflow/src/workflow-as-agent.ts b/packages/workflow/src/workflow-as-agent.ts deleted file mode 100644 index d6ac8b1..0000000 --- a/packages/workflow/src/workflow-as-agent.ts +++ /dev/null @@ -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 => { - 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}`; - } - }; -} diff --git a/packages/workflow/tsconfig.json b/packages/workflow/tsconfig.json deleted file mode 100644 index 72ffea1..0000000 --- a/packages/workflow/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/tsconfig.json b/tsconfig.json index 9257070..d2e95ff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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" },