From 3467b772e69ea1510eef41f2692b3970b6c661b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 6 May 2026 06:39:15 +0000 Subject: [PATCH] refactor: named exports (run + descriptor), remove build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bundle contract: export const run + export const descriptor (no default export) - add only accepts .esm.js, extracts descriptor via dynamic import → .yaml - Removed: build-pipeline, generate-types, json-schema-to-ts - Worker loads mod.run instead of mod.default - Biome: no more noDefaultExport overrides for bundles - 62 tests pass, biome clean Closes #8 小橘 --- CLAUDE.md | 6 +- biome.json | 10 - docs/rfc-001-workflow-engine.md | 52 +++- examples/hello-world.ts | 2 +- .../cli-workflow/__tests__/bundle-fixture.ts | 8 +- .../cli-workflow/__tests__/commands.test.ts | 138 ++++++--- .../cli-workflow/__tests__/fork-cli.test.ts | 17 +- .../cli-workflow/__tests__/thread-cli.test.ts | 46 +-- packages/cli-workflow/src/add-argv.ts | 92 ------ packages/cli-workflow/src/cli-dispatch.ts | 5 +- packages/cli-workflow/src/cmd-add.ts | 265 ++++++++++-------- .../workflow/__tests__/build-pipeline.test.ts | 34 --- .../__tests__/bundle-validator.test.ts | 83 ++++-- .../fixtures/minimal-build-workflow.ts | 30 -- packages/workflow/__tests__/hash.test.ts | 4 +- .../__tests__/json-schema-to-ts.test.ts | 37 --- packages/workflow/__tests__/worker.test.ts | 11 +- packages/workflow/src/build-pipeline.ts | 119 -------- packages/workflow/src/bundle-validator.ts | 204 ++++++++++---- .../workflow/src/create-role-moderator.ts | 2 +- .../workflow/src/extract-bundle-exports.ts | 43 +++ packages/workflow/src/generate-types.ts | 27 -- packages/workflow/src/index.ts | 7 +- packages/workflow/src/json-schema-to-ts.ts | 108 ------- packages/workflow/src/types.ts | 4 +- packages/workflow/src/worker.ts | 11 +- packages/workflow/src/workflow-descriptor.ts | 2 +- 27 files changed, 597 insertions(+), 770 deletions(-) delete mode 100644 packages/cli-workflow/src/add-argv.ts delete mode 100644 packages/workflow/__tests__/build-pipeline.test.ts delete mode 100644 packages/workflow/__tests__/fixtures/minimal-build-workflow.ts delete mode 100644 packages/workflow/__tests__/json-schema-to-ts.test.ts delete mode 100644 packages/workflow/src/build-pipeline.ts create mode 100644 packages/workflow/src/extract-bundle-exports.ts delete mode 100644 packages/workflow/src/generate-types.ts delete mode 100644 packages/workflow/src/json-schema-to-ts.ts diff --git a/CLAUDE.md b/CLAUDE.md index faf8499..89055e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ | Concept | What it is | |---------|-----------| -| **Workflow** | A single-file ESM module that default-exports a workflow function. Identified by its XXH64 hash (Crockford Base32). | +| **Workflow** | A single-file ESM module that exports `run` (workflow function) and `descriptor` (metadata). Identified by its XXH64 hash (Crockford Base32). | | **Bundle** | The physical `.esm.js` file stored in `~/.uncaged/workflow/bundles/`. | | **Thread** | A single execution of a workflow, identified by a ULID. Persisted as `.data.jsonl` + `.info.jsonl`. | | **Role** | A named actor within a workflow. Each role produces output with typed `meta`. | @@ -95,7 +95,7 @@ type WorkflowEntry = { - Always named exports, never default exports - One module = one responsibility, filename = purpose -**Exception**: Workflow bundle files (`.esm.js`) use default export by design — this is the user-authored extension point. +Workflow bundles (`.esm.js`) follow the same rule: export `const run` and `const descriptor`, not `export default`. ## Naming @@ -177,7 +177,7 @@ console.log(result); Do NOT use `await import()` in production code. Always use static top-level `import`. -**Exception**: The bundle loader must dynamically import user workflow files at runtime. +**Exception**: The bundle loader and `extractBundleExports` dynamically import user workflow files at runtime. ```ts // Dynamic import required: user bundle path resolved at runtime diff --git a/biome.json b/biome.json index 930f8e0..61030ca 100644 --- a/biome.json +++ b/biome.json @@ -38,16 +38,6 @@ } } } - }, - { - "includes": ["examples/**/*.ts", "packages/workflow/__tests__/fixtures/**/*.ts"], - "linter": { - "rules": { - "style": { - "noDefaultExport": "off" - } - } - } } ], "linter": { diff --git a/docs/rfc-001-workflow-engine.md b/docs/rfc-001-workflow-engine.md index 6b7f892..49f4762 100644 --- a/docs/rfc-001-workflow-engine.md +++ b/docs/rfc-001-workflow-engine.md @@ -19,7 +19,7 @@ Monorepo uses **bun workspace**. ## 2. Workflow Physical Implementation -A **Workflow** is a single-file ESM module that default-exports an **AsyncGenerator** function: +A **Workflow** is a single-file ESM module that **named-exports** an **AsyncGenerator** function as `run` and workflow metadata as `descriptor`: ```typescript /** What each yield produces — one role's output. */ @@ -54,8 +54,22 @@ The workflow **yields** each role output instead of writing to an injected write exporting a framework-specific shape: ```typescript -// Example bundle — zero framework dependency -export default async function* (input, options) { +// Example bundle — zero framework dependency (named exports only) +export const descriptor = { + description: "Fix auth bug", + roles: { + planner: { + description: "Plans the fix", + schema: { type: "object", properties: { files: { type: "array", items: { type: "string" } } } }, + }, + coder: { + description: "Implements the plan", + schema: { type: "object", properties: { diff: { type: "string" } } }, + }, + }, +}; + +export const run = async function* (input, options) { const plan = await callLLM("plan: " + input.prompt); yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } }; @@ -63,7 +77,7 @@ export default async function* (input, options) { yield { role: "coder", content: code, meta: { diff: "..." } }; return { returnCode: 0, summary: "Fixed auth bug" }; -} +}; ``` **Engine controls the loop**, not the bundle: @@ -104,14 +118,20 @@ any framework types. ### Constraints - Single `.esm.js` file +- Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object) +- No default export - No dynamic `import()` - All static imports must be Node built-in modules only This guarantees the file is self-contained, and its **XXH64 hash** (encoded as Crockford Base32) serves as a globally unique version identifier. -### Role Descriptor (Optional) +### Role Descriptor (`export const descriptor`) -A YAML file alongside the bundle describes roles for tooling/agent consumption: +The bundle **must** export a `descriptor` object describing roles for tooling/agent consumption. + +Shape: `{ description: string, roles: Record }` + +When you register a bundle via `uncaged-workflow add`, the engine imports the module, validates `descriptor`, and writes `{hash}.yaml` next to `{hash}.esm.js` under `bundles/` (same serialized shape as below): ```yaml description: "Workflow brief introduction" @@ -136,9 +156,7 @@ roles: type: string ``` -Format: `{ description: string, roles: Record }` - -This file is **not required** for execution. +Execution uses `run` only; YAML is for tooling and introspection. ## 3. Storage Layout @@ -148,7 +166,7 @@ All data lives under `~/.uncaged/workflow/`: ~/.uncaged/workflow/ ├── bundles/ # ESM bundles │ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash -│ └── C9NMV6V2TQT81.yaml # Role descriptor (optional) +│ └── C9NMV6V2TQT81.yaml # Role descriptor (from bundle export, at register time) ├── logs/ # Thread data, one folder per bundle hash │ └── C9NMV6V2TQT81/ │ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl @@ -249,7 +267,7 @@ No concurrency control or timeout settings in the registry — those belong to e | Command | Description | |---------|-------------| -| `uncaged-workflow add ` | Register a workflow bundle | +| `uncaged-workflow add [--types ]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) | | `uncaged-workflow list` | List registered workflows | | `uncaged-workflow show ` | Show workflow details | | `uncaged-workflow remove ` | Remove a workflow | @@ -292,9 +310,17 @@ function createRoleModerator( Usage in a bundle: ```typescript -import { createRoleModerator } from "@uncaged/workflow"; +import { createRoleModerator, END } from "@uncaged/workflow"; -export default createRoleModerator({ +export const descriptor = { + description: "Example multi-role workflow", + roles: { + planner: { description: "Plans work", schema: {} }, + coder: { description: "Writes code", schema: {} }, + }, +}; + +export const run = createRoleModerator({ roles: { planner, coder }, moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; }, }); diff --git a/examples/hello-world.ts b/examples/hello-world.ts index 96e3b74..777dadb 100644 --- a/examples/hello-world.ts +++ b/examples/hello-world.ts @@ -23,7 +23,7 @@ const greeter: Role = async (ctx) => ({ meta: { greeting: "Hello!" }, }); -export default createRoleModerator({ +export const run = createRoleModerator({ roles: { greeter }, moderator(ctx) { return ctx.steps.length === 0 ? "greeter" : END; diff --git a/packages/cli-workflow/__tests__/bundle-fixture.ts b/packages/cli-workflow/__tests__/bundle-fixture.ts index 8e99941..edc207e 100644 --- a/packages/cli-workflow/__tests__/bundle-fixture.ts +++ b/packages/cli-workflow/__tests__/bundle-fixture.ts @@ -1,9 +1,5 @@ -import type { ParsedAddArgv } from "../src/add-argv.js"; - -export const MINIMAL_DESCRIPTOR_YAML = `description: "fixture" -roles: {} -`; +import type { ParsedAddArgv } from "../src/cmd-add.js"; export function addCliArgs(name: string, filePath: string): ParsedAddArgv { - return { name, filePath, descriptorPath: null, typesPath: null }; + return { name, filePath, typesPath: null }; } diff --git a/packages/cli-workflow/__tests__/commands.test.ts b/packages/cli-workflow/__tests__/commands.test.ts index 0d2f04a..c7dfd2a 100644 --- a/packages/cli-workflow/__tests__/commands.test.ts +++ b/packages/cli-workflow/__tests__/commands.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { fileURLToPath } from "node:url"; import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow"; import { cmdAdd } from "../src/cmd-add.js"; @@ -11,7 +10,10 @@ import { cmdList, formatListLines } from "../src/cmd-list.js"; import { cmdRemove } from "../src/cmd-remove.js"; import { cmdRollback } from "../src/cmd-rollback.js"; import { cmdShow } from "../src/cmd-show.js"; -import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; +import { addCliArgs } from "./bundle-fixture.js"; + +const fixtureDescriptor = `export const descriptor = { description: "fixture", roles: {} }; +`; describe("cli workflow commands", () => { let prevEnv: string | undefined; @@ -38,9 +40,9 @@ describe("cli workflow commands", () => { const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile( bundlePath, - `import fs from "node:fs"; + `${fixtureDescriptor}import fs from "node:fs"; -export default async function* (input) { +export const run = async function* (input) { fs.existsSync("."); yield { role: "noop", content: input.prompt, meta: { done: true } }; return { returnCode: 0, summary: "done" }; @@ -48,7 +50,6 @@ export default async function* (input) { `, "utf8", ); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -87,18 +88,30 @@ export default async function* (input) { const bundlePath = join(storageRoot, "bad.esm.js"); await writeFile( bundlePath, - 'import x from "./local";\nexport default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n', + `${fixtureDescriptor}import x from "./local"; +export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; } +`, "utf8", ); const r = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(r.ok).toBe(false); }); - test("add rejects .esm.js without companion YAML", async () => { + test("add rejects .ts sources", async () => { + const tsPath = join(storageRoot, "solo.ts"); + await writeFile(tsPath, "export const x = 1;\n", "utf8"); + const r = await cmdAdd(storageRoot, addCliArgs("solo", tsPath)); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toContain("build your .ts file first"); + } + }); + + test("add rejects bundle without descriptor export", async () => { const bundlePath = join(storageRoot, "solo.esm.js"); await writeFile( bundlePath, - `export default async function* (input) { + `export const run = async function* (input) { yield { role: "x", content: input.prompt, meta: {} }; return { returnCode: 0, summary: "ok" }; } @@ -107,15 +120,34 @@ export default async function* (input) { ); const r = await cmdAdd(storageRoot, addCliArgs("solo", bundlePath)); expect(r.ok).toBe(false); - if (r.ok) { - return; + if (!r.ok) { + expect(r.error).toContain("descriptor"); } - expect(r.error).toContain("descriptor YAML not found"); }); - test("add from .ts builds bundle + yaml + d.ts and registers hash", async () => { - const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url)); - const added = await cmdAdd(storageRoot, addCliArgs("hello", helloTs)); + test("add from .esm.js writes yaml from descriptor export", async () => { + const bundleDir = join(storageRoot, "src"); + await mkdir(bundleDir, { recursive: true }); + const bundlePath = join(bundleDir, "hello.esm.js"); + await writeFile( + bundlePath, + `export const descriptor = { + description: "hello world fixture", + roles: { + greeter: { + description: "greet", + schema: { type: "object", properties: { greeting: { type: "string" } } }, + }, + }, +}; +export const run = async function* (input) { + yield { role: "greeter", content: input.prompt, meta: { greeting: "hi" } }; + return { returnCode: 0, summary: "ok" }; +}; +`, + "utf8", + ); + const added = await cmdAdd(storageRoot, addCliArgs("hello", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -125,10 +157,8 @@ export default async function* (input) { const esm = await readFile(join(bundles, `${hash}.esm.js`), "utf8"); expect(esm.length).toBeGreaterThan(100); const yaml = await readFile(join(bundles, `${hash}.yaml`), "utf8"); - expect(yaml).toContain("hello world"); - const dts = await readFile(join(bundles, `${hash}.d.ts`), "utf8"); - expect(dts).toContain("export type Roles"); - expect(dts).toContain("WorkflowFn"); + expect(yaml).toContain("hello world fixture"); + expect(yaml).toContain("greeter"); const reg = await readWorkflowRegistry(storageRoot); expect(reg.ok).toBe(true); @@ -143,37 +173,64 @@ export default async function* (input) { expect(entry.hash).toBe(hash); }); - test("add from .esm.js with --descriptor uses explicit YAML path", async () => { - const bundleDir = join(storageRoot, "w"); + test("add from .esm.js copies optional sidecar .d.ts", async () => { + const bundleDir = join(storageRoot, "src"); await mkdir(bundleDir, { recursive: true }); - const bundlePath = join(bundleDir, "app.esm.js"); - const yamlPath = join(bundleDir, "desc.yaml"); + const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile( bundlePath, - `export default async function* (input) { + `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "x", meta: {} }; return { returnCode: 0, summary: "x" }; } `, "utf8", ); - await writeFile(yamlPath, MINIMAL_DESCRIPTOR_YAML, "utf8"); + await writeFile( + join(bundleDir, "demo.d.ts"), + "export type DemoHint = { hint: string };\n", + "utf8", + ); + + const added = await cmdAdd(storageRoot, addCliArgs("typed-demo", bundlePath)); + expect(added.ok).toBe(true); + if (!added.ok) { + return; + } + const dts = await readFile(join(storageRoot, "bundles", `${added.value.hash}.d.ts`), "utf8"); + expect(dts).toContain("DemoHint"); + }); + + test("add from .esm.js with --types uses explicit d.ts path", async () => { + const bundleDir = join(storageRoot, "w"); + await mkdir(bundleDir, { recursive: true }); + const bundlePath = join(bundleDir, "app.esm.js"); + const dtsPath = join(bundleDir, "types.d.ts"); + await writeFile( + bundlePath, + `${fixtureDescriptor}export const run = async function* (input) { + yield { role: "a", content: "x", meta: {} }; + return { returnCode: 0, summary: "x" }; +} +`, + "utf8", + ); + await writeFile(dtsPath, "export type App = 1;\n", "utf8"); const added = await cmdAdd(storageRoot, { name: "app", filePath: bundlePath, - descriptorPath: yamlPath, - typesPath: null, + typesPath: dtsPath, }); expect(added.ok).toBe(true); if (!added.ok) { return; } - const yamlStored = await readFile( - join(storageRoot, "bundles", `${added.value.hash}.yaml`), + const dtsStored = await readFile( + join(storageRoot, "bundles", `${added.value.hash}.d.ts`), "utf8", ); - expect(yamlStored).toContain("fixture"); + expect(dtsStored).toContain("App"); }); test("add from .esm.js warns when optional .d.ts is missing", async () => { @@ -182,14 +239,13 @@ export default async function* (input) { const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile( bundlePath, - `export default async function* (input) { + `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "x", meta: {} }; return { returnCode: 0, summary: "x" }; } `, "utf8", ); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -204,18 +260,17 @@ export default async function* (input) { const bundleDir = join(storageRoot, "src"); await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); - const v1 = `export default async function* (input) { + const v1 = `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "v1", meta: {} }; return { returnCode: 0, summary: "v1" }; } `; - const v2 = `export default async function* (input) { + const v2 = `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "v2", meta: {} }; return { returnCode: 0, summary: "v2" }; } `; await writeFile(bundlePath, v1, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); await new Promise((r) => setTimeout(r, 15)); @@ -243,18 +298,17 @@ export default async function* (input) { const bundleDir = join(storageRoot, "src"); await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); - const v1 = `export default async function* (input) { + const v1 = `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "v1", meta: {} }; return { returnCode: 0, summary: "v1" }; } `; - const v2 = `export default async function* (input) { + const v2 = `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "v2", meta: {} }; return { returnCode: 0, summary: "v2" }; } `; await writeFile(bundlePath, v1, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); if (!add1.ok) { @@ -292,19 +346,18 @@ export default async function* (input) { const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile( bundlePath, - `export default async function* (input) { + `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "x", meta: {} }; return { returnCode: 0, summary: "x" }; } `, "utf8", ); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); await writeFile( bundlePath, - `export default async function* (input) { + `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "y", meta: {} }; return { returnCode: 0, summary: "y" }; } @@ -324,14 +377,13 @@ export default async function* (input) { const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile( bundlePath, - `export default async function* (input) { + `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "x", meta: {} }; return { returnCode: 0, summary: "x" }; } `, "utf8", ); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); if (!add1.ok) { @@ -340,7 +392,7 @@ export default async function* (input) { const hash1 = add1.value.hash; await writeFile( bundlePath, - `export default async function* (input) { + `${fixtureDescriptor}export const run = async function* (input) { yield { role: "a", content: "y", meta: {} }; return { returnCode: 0, summary: "y" }; } diff --git a/packages/cli-workflow/__tests__/fork-cli.test.ts b/packages/cli-workflow/__tests__/fork-cli.test.ts index 23c4248..154398f 100644 --- a/packages/cli-workflow/__tests__/fork-cli.test.ts +++ b/packages/cli-workflow/__tests__/fork-cli.test.ts @@ -6,10 +6,18 @@ import { cmdAdd } from "../src/cmd-add.js"; import { cmdFork } from "../src/cmd-fork.js"; import { cmdRun } from "../src/cmd-run.js"; import { pathExists } from "../src/fs-utils.js"; -import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; +import { addCliArgs } from "./bundle-fixture.js"; /** Three-role workflow that respects `input.steps` for fork/resume. */ -const threeRoleBundleSource = `export default async function* (input) { +const threeRoleBundleSource = `export const descriptor = { + description: "fork-cli", + roles: { + planner: { description: "planner", schema: {} }, + coder: { description: "coder", schema: {} }, + reviewer: { description: "reviewer", schema: {} }, + }, +}; +export const run = async function* (input) { const has = (r) => input.steps.some((s) => s.role === r); if (!has("planner")) { yield { role: "planner", content: "p1", meta: { k: "planner" } }; @@ -25,7 +33,7 @@ const threeRoleBundleSource = `export default async function* (input) { }; } return { returnCode: 0, summary: "done" }; -} +}; `; async function countDataJsonlLines(dataPath: string): Promise { @@ -82,7 +90,6 @@ describe("cli fork", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, threeRoleBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -133,7 +140,6 @@ describe("cli fork", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, threeRoleBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -185,7 +191,6 @@ describe("cli fork", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, threeRoleBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); diff --git a/packages/cli-workflow/__tests__/thread-cli.test.ts b/packages/cli-workflow/__tests__/thread-cli.test.ts index c00c25d..95b8184 100644 --- a/packages/cli-workflow/__tests__/thread-cli.test.ts +++ b/packages/cli-workflow/__tests__/thread-cli.test.ts @@ -13,46 +13,64 @@ import { cmdRun } from "../src/cmd-run.js"; import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js"; import { cmdThreads } from "../src/cmd-threads.js"; import { pathExists } from "../src/fs-utils.js"; -import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; +import { addCliArgs } from "./bundle-fixture.js"; -const fastBundleSource = `export default async function* (input) { +const threadFixtureDescriptor = `export const descriptor = { + description: "thread-cli", + roles: { + planner: { description: "planner", schema: {} }, + coder: { description: "coder", schema: {} }, + first: { description: "first", schema: {} }, + second: { description: "second", schema: {} }, + only: { description: "only", schema: {} }, + noop: { description: "noop", schema: {} }, + }, +}; +`; + +const fastBundleSource = `${threadFixtureDescriptor} +export const run = async function* (input) { yield { role: "planner", content: "plan", meta: { plan: input.prompt } }; yield { role: "coder", content: "code", meta: { diff: "y" } }; return { returnCode: 0, summary: "done" }; -} +}; `; -const slowPlannerBundleSource = `export default async function* (input) { +const slowPlannerBundleSource = `${threadFixtureDescriptor} +export const run = async function* (input) { await new Promise((r) => setTimeout(r, 400)); yield { role: "planner", content: "plan", meta: { plan: input.prompt } }; yield { role: "coder", content: "code", meta: { diff: "y" } }; return { returnCode: 0, summary: "done" }; -} +}; `; const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url)); -const abortablePlannerBundleSource = `export default async function* (input) { +const abortablePlannerBundleSource = `${threadFixtureDescriptor} +export const run = async function* (input) { await new Promise((r) => setTimeout(r, 600)); yield { role: "planner", content: "plan", meta: { plan: input.prompt } }; yield { role: "coder", content: "code", meta: { diff: "y" } }; return { returnCode: 0, summary: "done" }; -} +}; `; -const pauseResumeBundleSource = `export default async function* (input) { +const pauseResumeBundleSource = `${threadFixtureDescriptor} +export const run = async function* (input) { yield { role: "first", content: "f", meta: {} }; await new Promise((r) => setTimeout(r, 1500)); yield { role: "second", content: "s", meta: {} }; return { returnCode: 0, summary: "done" }; -} +}; `; -const delayedFirstYieldBundleSource = `export default async function* (input) { +const delayedFirstYieldBundleSource = `${threadFixtureDescriptor} +export const run = async function* (input) { await new Promise((r) => setTimeout(r, 900)); yield { role: "only", content: "x", meta: {} }; return { returnCode: 0, summary: "done" }; -} +}; `; async function countDataJsonlLines(dataPath: string): Promise { @@ -113,7 +131,6 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, fastBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -175,7 +192,6 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, slowPlannerBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -206,7 +222,6 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, abortablePlannerBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -246,7 +261,6 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, pauseResumeBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -288,7 +302,6 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, fastBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); @@ -318,7 +331,6 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, delayedFirstYieldBundleSource, "utf8"); - await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); diff --git a/packages/cli-workflow/src/add-argv.ts b/packages/cli-workflow/src/add-argv.ts deleted file mode 100644 index 479af4c..0000000 --- a/packages/cli-workflow/src/add-argv.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { err, ok, type Result } from "@uncaged/workflow"; - -export type ParsedAddArgv = { - name: string; - filePath: string; - /** Override path to descriptor YAML when adding an `.esm.js` bundle. */ - descriptorPath: string | null; - /** Override path to `.d.ts` when adding an `.esm.js` bundle. */ - typesPath: string | null; -}; - -type ParsedLongFlag = - | { advance: 2; kind: "descriptor"; value: string } - | { advance: 2; kind: "types"; value: string }; - -type PositionalSlots = { - name: string | undefined; - filePath: string | undefined; -}; - -function assignPositional(tok: string, slots: PositionalSlots): Result { - if (slots.name === undefined) { - slots.name = tok; - return ok(undefined); - } - if (slots.filePath === undefined) { - slots.filePath = tok; - return ok(undefined); - } - return err("too many arguments"); -} - -function tryParseAddLongFlag(argv: string[], index: number): Result { - const tok = argv[index]; - if (tok !== "--descriptor" && tok !== "--types") { - return ok(null); - } - const value = argv[index + 1]; - if (value === undefined || value.startsWith("--")) { - return err( - tok === "--descriptor" ? "missing value for --descriptor" : "missing value for --types", - ); - } - if (tok === "--descriptor") { - return ok({ advance: 2, kind: "descriptor", value }); - } - return ok({ advance: 2, kind: "types", value }); -} - -export function parseAddArgv(argv: string[]): Result { - const slots: PositionalSlots = { name: undefined, filePath: undefined }; - let descriptorPath: string | null = null; - let typesPath: string | null = null; - - let i = 0; - while (i < argv.length) { - const flag = tryParseAddLongFlag(argv, i); - if (!flag.ok) { - return flag; - } - if (flag.value !== null) { - const f = flag.value; - if (f.kind === "descriptor") { - descriptorPath = f.value; - } else { - typesPath = f.value; - } - i += f.advance; - continue; - } - - const tok = argv[i]; - if (tok?.startsWith("--")) { - return err(`unknown add flag: ${tok}`); - } - if (tok === undefined) { - break; - } - const placed = assignPositional(tok, slots); - if (!placed.ok) { - return placed; - } - i += 1; - } - - const { name, filePath } = slots; - if (name === undefined || name === "" || filePath === undefined || filePath === "") { - return err("add requires "); - } - - return ok({ name, filePath, descriptorPath, typesPath }); -} diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index abe8c9e..baf106e 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -1,6 +1,5 @@ -import { parseAddArgv } from "./add-argv.js"; import { printCliError, printCliLine, printCliWarn } from "./cli-output.js"; -import { cmdAdd, formatAddSuccess } from "./cmd-add.js"; +import { cmdAdd, formatAddSuccess, parseAddArgv } from "./cmd-add.js"; import { cmdFork, parseForkArgv } from "./cmd-fork.js"; import { cmdHistory } from "./cmd-history.js"; import { cmdKill } from "./cmd-kill.js"; @@ -19,7 +18,7 @@ import { parseRunArgv } from "./run-argv.js"; function usage(): string { return [ "Usage:", - " uncaged-workflow add [--descriptor ] [--types ]", + " uncaged-workflow add [--types ]", " uncaged-workflow list", " uncaged-workflow show ", " uncaged-workflow remove ", diff --git a/packages/cli-workflow/src/cmd-add.ts b/packages/cli-workflow/src/cmd-add.ts index 0e44be9..0833bbf 100644 --- a/packages/cli-workflow/src/cmd-add.ts +++ b/packages/cli-workflow/src/cmd-add.ts @@ -2,42 +2,110 @@ import { readFile, stat } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { - buildWorkflowFromTypeScript, err, + extractBundleExports, hashWorkflowBundleBytes, ok, type Result, readWorkflowRegistry, registerWorkflowVersion, + stringifyWorkflowDescriptor, validateWorkflowBundle, writeWorkflowRegistry, } from "@uncaged/workflow"; -import type { ParsedAddArgv } from "./add-argv.js"; import { storeWorkflowBundleArtifacts } from "./bundle-store.js"; import { validateCliWorkflowName } from "./workflow-name.js"; +export type ParsedAddArgv = { + name: string; + filePath: string; + /** Override path to `.d.ts` when adding a bundle. */ + typesPath: string | null; +}; + export type CmdAddSuccess = { hash: string; warnings: ReadonlyArray; }; -function isTypeScriptWorkflow(path: string): boolean { - return path.endsWith(".ts"); -} - function isEsmBundle(path: string): boolean { return path.endsWith(".esm.js"); } -function defaultDescriptorPath(bundlePath: string): string { - return bundlePath.replace(/\.esm\.js$/i, ".yaml"); -} - function defaultTypesPath(bundlePath: string): string { return bundlePath.replace(/\.esm\.js$/i, ".d.ts"); } +type ParsedLongFlag = { advance: 2; kind: "types"; value: string }; + +function tryParseAddLongFlag(argv: string[], index: number): Result { + const tok = argv[index]; + if (tok !== "--types") { + return ok(null); + } + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + return err("missing value for --types"); + } + return ok({ advance: 2, kind: "types", value }); +} + +type PositionalSlots = { + name: string | undefined; + filePath: string | undefined; +}; + +function assignPositional(tok: string, slots: PositionalSlots): Result { + if (slots.name === undefined) { + slots.name = tok; + return ok(undefined); + } + if (slots.filePath === undefined) { + slots.filePath = tok; + return ok(undefined); + } + return err("too many arguments"); +} + +export function parseAddArgv(argv: string[]): Result { + const slots: PositionalSlots = { name: undefined, filePath: undefined }; + let typesPath: string | null = null; + + let i = 0; + while (i < argv.length) { + const flag = tryParseAddLongFlag(argv, i); + if (!flag.ok) { + return flag; + } + if (flag.value !== null) { + typesPath = flag.value.value; + i += flag.value.advance; + continue; + } + + const tok = argv[i]; + if (tok?.startsWith("--")) { + return err(`unknown add flag: ${tok}`); + } + if (tok === undefined) { + break; + } + const placed = assignPositional(tok, slots); + if (!placed.ok) { + return placed; + } + i += 1; + } + + const { name, filePath } = slots; + if (name === undefined || name === "" || filePath === undefined || filePath === "") { + return err("add requires "); + } + + return ok({ name, filePath, typesPath }); +} + async function registerHash( storageRoot: string, name: string, @@ -56,125 +124,31 @@ async function registerHash( return ok(undefined); } -async function addFromTypeScript( - storageRoot: string, - workflowName: string, - resolvedTsPath: string, -): Promise> { - const built = await buildWorkflowFromTypeScript(resolvedTsPath); - if (!built.ok) { - return built; - } - - const encoder = new TextEncoder(); - const bytes = encoder.encode(built.value.esmJsSource); - const hash = hashWorkflowBundleBytes(bytes); - - const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { - esmJs: { kind: "text", text: built.value.esmJsSource }, - yaml: { kind: "text", text: built.value.yamlSource }, - dts: { kind: "text", text: built.value.dtsSource }, - }); - if (!stored.ok) { - return stored; - } - - const regResult = await registerHash(storageRoot, workflowName, hash); - if (!regResult.ok) { - return regResult; - } - - return ok({ hash, warnings: [] }); -} - -async function resolveYamlAndOptionalTypes( - args: ParsedAddArgv, +async function resolveOptionalTypes( + typesPathFlag: string | null, resolvedBundlePath: string, -): Promise> { +): Promise> { const warnings: string[] = []; - const yamlResolved = - args.descriptorPath !== null - ? resolve(args.descriptorPath) - : defaultDescriptorPath(resolvedBundlePath); - - let yamlText: string; - try { - yamlText = await readFile(yamlResolved, "utf8"); - } catch { - return err(`descriptor YAML not found: ${yamlResolved}`); - } - let dtsText: string | null = null; - if (args.typesPath !== null) { - const typesResolved = resolve(args.typesPath); + + if (typesPathFlag !== null) { + const typesResolved = resolve(typesPathFlag); try { dtsText = await readFile(typesResolved, "utf8"); } catch { return err(`types file not found: ${typesResolved}`); } - } else { - const typesDefault = defaultTypesPath(resolvedBundlePath); - try { - dtsText = await readFile(typesDefault, "utf8"); - } catch { - warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`); - } + return ok({ dtsText, warnings }); } - return ok({ yamlText, dtsText, warnings }); -} - -async function addFromEsmJs( - storageRoot: string, - workflowName: string, - args: ParsedAddArgv, - resolvedBundlePath: string, -): Promise> { - let source: string; + const typesDefault = defaultTypesPath(resolvedBundlePath); try { - source = await readFile(resolvedBundlePath, "utf8"); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return err(`failed to read bundle: ${message}`); + dtsText = await readFile(typesDefault, "utf8"); + } catch { + warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`); } - const validated = validateWorkflowBundle({ - filePath: resolvedBundlePath, - source, - }); - if (!validated.ok) { - return validated; - } - - const companions = await resolveYamlAndOptionalTypes(args, resolvedBundlePath); - if (!companions.ok) { - return companions; - } - - const encoder = new TextEncoder(); - const bytes = encoder.encode(source); - const hash = hashWorkflowBundleBytes(bytes); - - const dts = - companions.value.dtsText === null - ? null - : { kind: "text" as const, text: companions.value.dtsText }; - - const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { - esmJs: { kind: "text", text: source }, - yaml: { kind: "text", text: companions.value.yamlText }, - dts, - }); - if (!stored.ok) { - return stored; - } - - const regResult = await registerHash(storageRoot, workflowName, hash); - if (!regResult.ok) { - return regResult; - } - - return ok({ hash, warnings: companions.value.warnings }); + return ok({ dtsText, warnings }); } export async function cmdAdd( @@ -194,15 +168,66 @@ export async function cmdAdd( return err(`file not found: ${args.filePath}`); } - if (isTypeScriptWorkflow(resolvedPath)) { - return addFromTypeScript(storageRoot, args.name, resolvedPath); + if (resolvedPath.endsWith(".ts")) { + return err("build your .ts file first, then add the .esm.js"); } if (!isEsmBundle(resolvedPath)) { - return err('workflow file must be ".ts" or end with ".esm.js"'); + return err('workflow file must end with ".esm.js"'); } - return addFromEsmJs(storageRoot, args.name, args, resolvedPath); + let source: string; + try { + source = await readFile(resolvedPath, "utf8"); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to read bundle: ${message}`); + } + + const validated = validateWorkflowBundle({ + filePath: resolvedPath, + source, + }); + if (!validated.ok) { + return validated; + } + + const extracted = await extractBundleExports(resolvedPath); + if (!extracted.ok) { + return extracted; + } + + const yamlSource = stringifyWorkflowDescriptor(extracted.value.descriptor); + + const companions = await resolveOptionalTypes(args.typesPath, resolvedPath); + if (!companions.ok) { + return companions; + } + + const encoder = new TextEncoder(); + const bytes = encoder.encode(source); + const hash = hashWorkflowBundleBytes(bytes); + + const dts = + companions.value.dtsText === null + ? null + : { kind: "text" as const, text: companions.value.dtsText }; + + const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { + esmJs: { kind: "text", text: source }, + yaml: { kind: "text", text: yamlSource }, + dts, + }); + if (!stored.ok) { + return stored; + } + + const regResult = await registerHash(storageRoot, args.name, hash); + if (!regResult.ok) { + return regResult; + } + + return ok({ hash, warnings: companions.value.warnings }); } export function formatAddSuccess(name: string, filePath: string, hash: string): string { diff --git a/packages/workflow/__tests__/build-pipeline.test.ts b/packages/workflow/__tests__/build-pipeline.test.ts deleted file mode 100644 index ee0b775..0000000 --- a/packages/workflow/__tests__/build-pipeline.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { buildWorkflowFromTypeScript } from "../src/build-pipeline.js"; - -describe("buildWorkflowFromTypeScript", () => { - test("produces ESM + YAML + d.ts and the bundle default export runs", async () => { - const thisFile = fileURLToPath(import.meta.url); - const entryTs = join(dirname(thisFile), "fixtures/minimal-build-workflow.ts"); - - const r = await buildWorkflowFromTypeScript(entryTs); - expect(r.ok).toBe(true); - if (!r.ok) { - return; - } - expect(r.value.esmJsSource.length).toBeGreaterThan(200); - expect(r.value.yamlSource).toContain("minimal fixture"); - expect(r.value.dtsSource).toContain("r:"); - expect(r.value.dtsSource).toContain("x: string"); - - const dir = await mkdtemp(join(tmpdir(), "uncaged-wf-build-")); - try { - const out = join(dir, "workflow.esm.js"); - await writeFile(out, r.value.esmJsSource, "utf8"); - const mod = (await import(pathToFileURL(out).href)) as { default: unknown }; - expect(typeof mod.default).toBe("function"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/workflow/__tests__/bundle-validator.test.ts b/packages/workflow/__tests__/bundle-validator.test.ts index 2639fba..65f66af 100644 --- a/packages/workflow/__tests__/bundle-validator.test.ts +++ b/packages/workflow/__tests__/bundle-validator.test.ts @@ -2,22 +2,25 @@ import { describe, expect, test } from "bun:test"; import { validateWorkflowBundle } from "../src/bundle-validator.js"; +const minimalDescriptor = `export const descriptor = { description: "x", roles: {} }; +`; + describe("validateWorkflowBundle", () => { - test("accepts export { local as default } when local is a call expression result", () => { - const source = `var wf = createFn({}); -export { wf as default }; + 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 = `import fs from "node:fs"; + const source = `${minimalDescriptor}import fs from "node:fs"; -export default async function* (input) { +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); @@ -26,27 +29,15 @@ export default async function* (input) { test("rejects wrong filename suffix", () => { const r = validateWorkflowBundle({ filePath: "/tmp/w.js", - source: - "export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n", + source: `${minimalDescriptor}export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n`, }); expect(r.ok).toBe(false); }); - test("rejects default export that is not a callable bundle shape", () => { + test("rejects default export", () => { const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", - source: 'export default { name: "x", roles: {}, moderator() { return "__end__"; } };\n', - }); - expect(r.ok).toBe(false); - if (!r.ok) { - expect(r.error).toContain("default export must be a function"); - } - }); - - test("rejects missing default export", () => { - const r = validateWorkflowBundle({ - filePath: "/tmp/w.esm.js", - source: "export const x = 1;\n", + source: `${minimalDescriptor}export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n`, }); expect(r.ok).toBe(false); if (!r.ok) { @@ -54,11 +45,49 @@ export default async function* (input) { } }); + 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: - 'import x from "some-package";\nexport default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n', + source: `${minimalDescriptor}import x from "some-package"; +export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; } +`, }); expect(r.ok).toBe(false); }); @@ -66,8 +95,8 @@ export default async function* (input) { test("rejects dynamic import", () => { const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", - source: - 'export default async function* (input) { await import("fs"); return { returnCode: 0, summary: input.prompt }; }\n', + 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) { @@ -78,8 +107,8 @@ export default async function* (input) { test("rejects require()", () => { const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", - source: - 'export default async function* (input) { require("fs"); return { returnCode: 0, summary: input.prompt }; }\n', + 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__/fixtures/minimal-build-workflow.ts b/packages/workflow/__tests__/fixtures/minimal-build-workflow.ts deleted file mode 100644 index 981431f..0000000 --- a/packages/workflow/__tests__/fixtures/minimal-build-workflow.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createRoleModerator } from "../../src/create-role-moderator.js"; -import { END, type Role } from "../../src/types.js"; - -type RMeta = { x: string }; - -export const descriptor = { - description: "minimal fixture workflow for build-pipeline tests", - roles: { - r: { - description: "single role", - schema: { - type: "object", - properties: { x: { type: "string" } }, - required: ["x"], - }, - }, - }, -}; - -const r: Role = async () => ({ - content: "", - meta: { x: "y" }, -}); - -export default createRoleModerator({ - roles: { r }, - moderator() { - return END; - }, -}); diff --git a/packages/workflow/__tests__/hash.test.ts b/packages/workflow/__tests__/hash.test.ts index 23cbdd4..1ddcf9d 100644 --- a/packages/workflow/__tests__/hash.test.ts +++ b/packages/workflow/__tests__/hash.test.ts @@ -17,7 +17,9 @@ describe("hashWorkflowBundleBytes", () => { test("stable for identical content", () => { const encoder = new TextEncoder(); const data = encoder.encode( - "export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n", + `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__/json-schema-to-ts.test.ts b/packages/workflow/__tests__/json-schema-to-ts.test.ts deleted file mode 100644 index 62b48eb..0000000 --- a/packages/workflow/__tests__/json-schema-to-ts.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { jsonSchemaToTypeString } from "../src/json-schema-to-ts.js"; - -describe("jsonSchemaToTypeString", () => { - test("maps primitives and object required fields", () => { - const schema = { - type: "object", - properties: { - plan: { type: "string" }, - files: { type: "array", items: { type: "string" } }, - }, - required: ["plan", "files"], - }; - expect(jsonSchemaToTypeString(schema)).toBe("{ plan: string; files: string[] }"); - }); - - test("marks non-required object properties as nullable union", () => { - const schema = { - type: "object", - properties: { - n: { type: "number" }, - }, - required: [], - }; - expect(jsonSchemaToTypeString(schema)).toBe("{ n: number | null }"); - }); - - test("handles boolean and integer", () => { - expect(jsonSchemaToTypeString({ type: "boolean" })).toBe("boolean"); - expect(jsonSchemaToTypeString({ type: "integer" })).toBe("number"); - }); - - test("handles enum as literal union", () => { - expect(jsonSchemaToTypeString({ enum: ["a", "b"] })).toBe(`"a" | "b"`); - }); -}); diff --git a/packages/workflow/__tests__/worker.test.ts b/packages/workflow/__tests__/worker.test.ts index c4200ca..bf86ebc 100644 --- a/packages/workflow/__tests__/worker.test.ts +++ b/packages/workflow/__tests__/worker.test.ts @@ -7,7 +7,14 @@ import { join } from "node:path"; import { getWorkerHostScriptPath } from "../src/worker-entry-path.js"; -const bundleSource = `export default async function* (input) { +const bundleSource = `export const descriptor = { + description: "worker-test", + roles: { + planner: { description: "planner", schema: {} }, + coder: { description: "coder", schema: {} }, + }, +}; +export const run = async function* (input) { const has = (r) => input.steps.some((s) => s.role === r); if (!has("planner")) { yield { role: "planner", content: "p", meta: { plan: input.prompt } }; @@ -16,7 +23,7 @@ const bundleSource = `export default async function* (input) { yield { role: "coder", content: "c", meta: { diff: "y" } }; } return { returnCode: 0, summary: "completed: moderator returned END" }; -} +}; `; async function readReadyPort(child: import("node:child_process").ChildProcess): Promise { diff --git a/packages/workflow/src/build-pipeline.ts b/packages/workflow/src/build-pipeline.ts deleted file mode 100644 index cf93f24..0000000 --- a/packages/workflow/src/build-pipeline.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { access, constants } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { pathToFileURL } from "node:url"; - -import { validateWorkflowBundle } from "./bundle-validator.js"; -import { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; -import { generateWorkflowBundleTypes } from "./generate-types.js"; -import { err, ok, type Result } from "./result.js"; -import { validateWorkflowDescriptor } from "./workflow-descriptor.js"; - -export type BuildPipelineResult = { - esmJsSource: string; - yamlSource: string; - dtsSource: string; -}; - -async function findPackageRoot(startDir: string): Promise { - let dir = startDir; - for (;;) { - try { - await access(join(dir, "package.json"), constants.R_OK); - return dir; - } catch { - const parent = dirname(dir); - if (parent === dir) { - return startDir; - } - dir = parent; - } - } -} - -async function loadDescriptorFromSourceTs( - absoluteTsPath: string, -): Promise> { - let mod: Record; - try { - const href = pathToFileURL(absoluteTsPath).href; - // Dynamic import required: user workflow source path resolved at add/build time - mod = (await import(href)) as Record; - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return err(`failed to import workflow source for descriptor: ${message}`); - } - - const raw = mod.descriptor; - return validateWorkflowDescriptor(raw); -} - -/** - * Bundle a `.ts` workflow entry with Bun, read `export const descriptor`, and emit - * companion YAML + `.d.ts` text alongside validated ESM bundle source. - */ -export async function buildWorkflowFromTypeScript( - absoluteTsPath: string, -): Promise> { - let rootDir: string; - try { - rootDir = await findPackageRoot(dirname(absoluteTsPath)); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return err(`failed to resolve package root: ${message}`); - } - - let buildResult: Awaited>; - try { - buildResult = await Bun.build({ - entrypoints: [absoluteTsPath], - target: "node", - format: "esm", - external: ["node:*"], - root: rootDir, - }); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return err(`Bun.build failed: ${message}`); - } - - if (!buildResult.success) { - const logs = buildResult.logs.map((l) => l.message).join("; "); - return err(`Bun.build failed: ${logs || "unknown error"}`); - } - - const entry = buildResult.outputs.find((o) => o.kind === "entry-point"); - if (entry === undefined) { - return err("Bun.build produced no entry-point output"); - } - - let esmJsSource: string; - try { - esmJsSource = await entry.text(); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - return err(`failed to read bundle output: ${message}`); - } - - const descriptorLoaded = await loadDescriptorFromSourceTs(absoluteTsPath); - if (!descriptorLoaded.ok) { - return descriptorLoaded; - } - const descriptor = descriptorLoaded.value; - - const validated = validateWorkflowBundle({ - filePath: joinVirtualEsmPath(absoluteTsPath), - source: esmJsSource, - }); - if (!validated.ok) { - return validated; - } - - const yamlSource = stringifyWorkflowDescriptor(descriptor); - const dtsSource = generateWorkflowBundleTypes(descriptor); - - return ok({ esmJsSource, yamlSource, dtsSource }); -} - -function joinVirtualEsmPath(absoluteTsPath: string): string { - return `${absoluteTsPath}.esm.js`; -} diff --git a/packages/workflow/src/bundle-validator.ts b/packages/workflow/src/bundle-validator.ts index 7a004eb..e33a897 100644 --- a/packages/workflow/src/bundle-validator.ts +++ b/packages/workflow/src/bundle-validator.ts @@ -2,7 +2,6 @@ import { isBuiltin } from "node:module"; import type { CallExpression, ExportAllDeclaration, - ExportDefaultDeclaration, ExportNamedDeclaration, ExportSpecifier, FunctionDeclaration, @@ -69,54 +68,34 @@ function walkAst(node: Node, visit: (n: Node) => void): void { } } -function exportSpecifierIsDefaultReExport(spec: ExportSpecifier): boolean { - return spec.exported.type === "Identifier" && spec.exported.name === "default"; +function exportSpecifierExportedName(spec: ExportSpecifier): string | null { + if (spec.exported.type !== "Identifier") { + return null; + } + return spec.exported.name; } -function exportNamedDeclarationOffersDefault(named: ExportNamedDeclaration): boolean { +function exportNamedDeclReExportsDefault(named: ExportNamedDeclaration): boolean { if (named.source !== null && named.source !== undefined) { return false; } return named.specifiers.some( - (spec) => spec.type === "ExportSpecifier" && exportSpecifierIsDefaultReExport(spec), + (spec) => spec.type === "ExportSpecifier" && exportSpecifierExportedName(spec) === "default", ); } -function programHasDefaultExport(body: readonly Node[]): boolean { - for (const stmt of body) { +function programUsesDefaultExport(program: Program): boolean { + for (const stmt of program.body) { if (stmt.type === "ExportDefaultDeclaration") { return true; } - if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclarationOffersDefault(stmt)) { + if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclReExportsDefault(stmt)) { return true; } } return false; } -function findDefaultExportLocalBindingName(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" || !exportSpecifierIsDefaultReExport(spec)) { - continue; - } - const loc = spec.local; - if (loc.type !== "Identifier") { - return null; - } - return loc.name; - } - } - return null; -} - function bindingInitializerIsCallable(init: Node): boolean { return ( init.type === "FunctionExpression" || @@ -157,32 +136,142 @@ function programDeclaresCallableExportBinding(program: Program, name: string): b return false; } -function defaultExportDeclarationIsCallable(program: Program): boolean { - for (const stmt of program.body) { - if (stmt.type !== "ExportDefaultDeclaration") { - continue; - } - const decl = (stmt as ExportDefaultDeclaration).declaration; - if ( - decl.type === "FunctionDeclaration" || - decl.type === "FunctionExpression" || - decl.type === "ArrowFunctionExpression" - ) { - return true; - } - if (decl.type === "CallExpression") { - return true; - } +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; +} - const exportBinding = findDefaultExportLocalBindingName(program); +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.value !== "string") { return null; @@ -263,8 +352,8 @@ function bundleConstraintViolationForNode(node: Node): string | null { } /** - * Validate RFC-001 bundle rules: single-file ESM shape, default export, - * no dynamic `import()`, static imports restricted to Node builtins. + * 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. */ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result { if (!endsWithEsmJs(input.filePath)) { @@ -288,13 +377,20 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re } const program = ast as Program; - if (!programHasDefaultExport(program.body)) { - return err("workflow bundle must have a default export"); + + if (programUsesDefaultExport(program)) { + return err('workflow bundle must not use default export; use "export const run" instead'); } - if (!defaultExportDeclarationIsCallable(program)) { + if (!runExportIsCallable(program)) { return err( - "workflow bundle default export must be a function (e.g. async function*) or a call expression that returns one", + '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 }")', ); } diff --git a/packages/workflow/src/create-role-moderator.ts b/packages/workflow/src/create-role-moderator.ts index abb2f11..e2f8f8c 100644 --- a/packages/workflow/src/create-role-moderator.ts +++ b/packages/workflow/src/create-role-moderator.ts @@ -20,7 +20,7 @@ function isRoleNext( /** * Role + Moderator pattern as an optional helper: returns a {@link WorkflowFn} that runs the - * moderator loop and yields each {@link RoleOutput}. + * moderator loop and yields each {@link RoleOutput}. Assign with `export const run = createRoleModerator(...)`. */ export function createRoleModerator( def: Pick, "roles" | "moderator">, diff --git a/packages/workflow/src/extract-bundle-exports.ts b/packages/workflow/src/extract-bundle-exports.ts new file mode 100644 index 0000000..82294fc --- /dev/null +++ b/packages/workflow/src/extract-bundle-exports.ts @@ -0,0 +1,43 @@ +import { pathToFileURL } from "node:url"; + +import { err, ok, type Result } from "./result.js"; +import type { WorkflowFn } from "./types.js"; +import type { WorkflowDescriptor } from "./workflow-descriptor.js"; +import { validateWorkflowDescriptor } from "./workflow-descriptor.js"; + +export type ExtractedBundleExports = { + run: WorkflowFn; + descriptor: WorkflowDescriptor; +}; + +/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */ +export async function extractBundleExports( + bundlePath: string, +): Promise> { + let modUnknown: unknown; + try { + // Dynamic import required: user bundle path resolved at runtime + modUnknown = await import(pathToFileURL(bundlePath).href); + } 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/generate-types.ts b/packages/workflow/src/generate-types.ts deleted file mode 100644 index 8c6653c..0000000 --- a/packages/workflow/src/generate-types.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { jsonSchemaToTypeString } from "./json-schema-to-ts.js"; -import type { WorkflowDescriptor } from "./workflow-descriptor.js"; - -function safePropertyName(name: string): string { - return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name); -} - -/** Build the standard workflow bundle `.d.ts` from role JSON Schemas. */ -export function generateWorkflowBundleTypes(descriptor: WorkflowDescriptor): string { - const roleLines: string[] = []; - for (const [roleName, role] of Object.entries(descriptor.roles)) { - const tsType = jsonSchemaToTypeString(role.schema); - roleLines.push(` ${safePropertyName(roleName)}: ${tsType};`); - } - - return [ - `import type { WorkflowFn } from "@uncaged/workflow";`, - ``, - `export type Roles = {`, - ...roleLines, - `};`, - ``, - `declare const workflow: WorkflowFn;`, - `export default workflow;`, - ``, - ].join("\n"); -} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 8c4716d..894f2be 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -5,10 +5,6 @@ export { encodeCrockfordBase32Bits, encodeUint64AsCrockford, } from "./base32.js"; -export { - type BuildPipelineResult, - buildWorkflowFromTypeScript, -} from "./build-pipeline.js"; export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js"; export { createRoleModerator } from "./create-role-moderator.js"; export { @@ -17,6 +13,7 @@ export { executeThread, type PrefilledDiskStep, } from "./engine.js"; +export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js"; export { buildForkPlan, type ForkHistoricalStep, @@ -26,9 +23,7 @@ export { selectForkHistoricalSteps, } from "./fork-thread.js"; export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; -export { generateWorkflowBundleTypes } from "./generate-types.js"; export { hashWorkflowBundleBytes } from "./hash.js"; -export { jsonSchemaToTypeString } from "./json-schema-to-ts.js"; export { type CreateLoggerOptions, createLogger, diff --git a/packages/workflow/src/json-schema-to-ts.ts b/packages/workflow/src/json-schema-to-ts.ts deleted file mode 100644 index 9ab25d4..0000000 --- a/packages/workflow/src/json-schema-to-ts.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Convert a JSON Schema subset (object / string / number / integer / boolean / array) - * into a TypeScript type string for generated `.d.ts` files. - */ -export function jsonSchemaToTypeString(schema: unknown): string { - return schemaToTs(schema); -} - -function schemaEnumToTs(record: Record): string | null { - const en = record.enum; - if (!Array.isArray(en) || en.length === 0) { - return null; - } - const literals = en - .filter((v): v is string | number | boolean => v !== null && v !== undefined) - .map((v) => (typeof v === "string" ? JSON.stringify(v) : String(v))); - if (literals.length === 0) { - return null; - } - return literals.join(" | "); -} - -function schemaArrayToTs(record: Record): string | null { - if (record.type !== "array") { - return null; - } - const items = record.items; - if (items === undefined || items === null) { - return "unknown[]"; - } - if (Array.isArray(items)) { - if (items.length === 0) { - return "unknown[]"; - } - const parts = items.map((it) => schemaToTs(it)); - return `[${parts.join(", ")}]`; - } - return `${schemaToTs(items)}[]`; -} - -function schemaObjectToTs(record: Record): string | null { - if (record.type !== "object") { - return null; - } - const propsRaw = record.properties; - const requiredRaw = record.required; - const required = new Set( - Array.isArray(requiredRaw) ? requiredRaw.filter((x): x is string => typeof x === "string") : [], - ); - - if (propsRaw === null || propsRaw === undefined) { - return "Record"; - } - if (typeof propsRaw !== "object" || Array.isArray(propsRaw)) { - return "Record"; - } - - const props = propsRaw as Record; - const entries = Object.entries(props); - if (entries.length === 0) { - return "{}"; - } - - const parts: string[] = []; - for (const [key, subSchema] of entries) { - const optional = !required.has(key); - const ts = schemaToTs(subSchema); - const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); - const suffix = optional ? " | null" : ""; - parts.push(`${safeKey}: ${ts}${suffix}`); - } - return `{ ${parts.join("; ")} }`; -} - -function schemaToTs(schema: unknown): string { - if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { - return "unknown"; - } - const record = schema as Record; - - const fromEnum = schemaEnumToTs(record); - if (fromEnum !== null) { - return fromEnum; - } - - const t = record.type; - if (t === "string") { - return "string"; - } - if (t === "number" || t === "integer") { - return "number"; - } - if (t === "boolean") { - return "boolean"; - } - - const fromArray = schemaArrayToTs(record); - if (fromArray !== null) { - return fromArray; - } - - const fromObject = schemaObjectToTs(record); - if (fromObject !== null) { - return fromObject; - } - - return "unknown"; -} diff --git a/packages/workflow/src/types.ts b/packages/workflow/src/types.ts index f4dfd9b..b8c1736 100644 --- a/packages/workflow/src/types.ts +++ b/packages/workflow/src/types.ts @@ -24,13 +24,13 @@ export type ThreadInput = { steps: RoleOutput[]; }; -/** Options passed to a workflow bundle's default-export function (engine-provided). */ +/** Options passed to a workflow bundle's `run` export (engine-provided). */ export type WorkflowFnOptions = { isDryRun: boolean; maxRounds: number; }; -/** Bundle contract — default export is a function returning an AsyncGenerator. */ +/** Bundle contract — named export `run` is a function returning an AsyncGenerator. */ export type WorkflowFn = ( input: ThreadInput, options: WorkflowFnOptions, diff --git a/packages/workflow/src/worker.ts b/packages/workflow/src/worker.ts index 3dc4d6c..3103734 100644 --- a/packages/workflow/src/worker.ts +++ b/packages/workflow/src/worker.ts @@ -295,16 +295,13 @@ async function main(): Promise { // Dynamic import required: user bundle path resolved at runtime const modUnknown: unknown = await import(pathToFileURL(bundlePath).href); const modRec = modUnknown as Record; - const defaultExport = modRec.default; - if (!isWorkflowFnLike(defaultExport)) { - bootLog( - "T4BW9YJX", - "workflow bundle default export must be a function (AsyncGenerator workflow)", - ); + 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 = defaultExport; + const workflowFn = runExport; const threads = new Map(); let activeThreads = 0; diff --git a/packages/workflow/src/workflow-descriptor.ts b/packages/workflow/src/workflow-descriptor.ts index 1324e6a..3676081 100644 --- a/packages/workflow/src/workflow-descriptor.ts +++ b/packages/workflow/src/workflow-descriptor.ts @@ -8,7 +8,7 @@ export type WorkflowRoleDescriptor = { schema: WorkflowRoleSchema; }; -/** Workflow metadata exported as `export const descriptor` from TypeScript sources. */ +/** Workflow metadata exported as `export const descriptor` from `.esm.js` bundles. */ export type WorkflowDescriptor = { description: string; roles: Record;