refactor: named exports (run + descriptor), remove build pipeline

- 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
小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 06:39:15 +00:00
parent e670047e6a
commit 3467b772e6
27 changed files with 597 additions and 770 deletions
+3 -3
View File
@@ -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
-10
View File
@@ -38,16 +38,6 @@
}
}
}
},
{
"includes": ["examples/**/*.ts", "packages/workflow/__tests__/fixtures/**/*.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
],
"linter": {
+39 -13
View File
@@ -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<string, { description: string, schema: JSONSchema }> }`
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<string, { description: string, schema: JSONSchema }> }`
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 <name> <file>` | Register a workflow bundle |
| `uncaged-workflow add <name> <file.esm.js> [--types <path>]` | Register a compiled `.esm.js` bundle (descriptor extracted from `export const descriptor`) |
| `uncaged-workflow list` | List registered workflows |
| `uncaged-workflow show <name>` | Show workflow details |
| `uncaged-workflow remove <name>` | Remove a workflow |
@@ -292,9 +310,17 @@ function createRoleModerator<M extends RoleMeta>(
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; },
});
+1 -1
View File
@@ -23,7 +23,7 @@ const greeter: Role<Roles["greeter"]> = async (ctx) => ({
meta: { greeting: "Hello!" },
});
export default createRoleModerator<Roles>({
export const run = createRoleModerator<Roles>({
roles: { greeter },
moderator(ctx) {
return ctx.steps.length === 0 ? "greeter" : END;
@@ -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 };
}
@@ -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" };
}
@@ -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<number> {
@@ -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);
@@ -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<number> {
@@ -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);
-92
View File
@@ -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<void, string> {
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<ParsedLongFlag | null, string> {
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<ParsedAddArgv, string> {
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 <name> <file>");
}
return ok({ name, filePath, descriptorPath, typesPath });
}
+2 -3
View File
@@ -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 <name> <file> [--descriptor <path>] [--types <path>]",
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
" uncaged-workflow list",
" uncaged-workflow show <name>",
" uncaged-workflow remove <name>",
+143 -118
View File
@@ -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<string>;
};
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<ParsedLongFlag | null, string> {
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<void, string> {
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<ParsedAddArgv, string> {
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 <name> <file>");
}
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<Result<CmdAddSuccess, string>> {
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<Result<{ yamlText: string; dtsText: string | null; warnings: string[] }, string>> {
): Promise<Result<{ dtsText: string | null; warnings: string[] }, string>> {
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 {
return ok({ dtsText, warnings });
}
const typesDefault = defaultTypesPath(resolvedBundlePath);
try {
dtsText = await readFile(typesDefault, "utf8");
} catch {
warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`);
}
}
return ok({ yamlText, dtsText, warnings });
}
async function addFromEsmJs(
storageRoot: string,
workflowName: string,
args: ParsedAddArgv,
resolvedBundlePath: string,
): Promise<Result<CmdAddSuccess, string>> {
let source: string;
try {
source = await readFile(resolvedBundlePath, "utf8");
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return err(`failed to read bundle: ${message}`);
}
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 {
@@ -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 });
}
});
});
@@ -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);
});
@@ -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<RMeta> = async () => ({
content: "",
meta: { x: "y" },
});
export default createRoleModerator({
roles: { r },
moderator() {
return END;
},
});
+3 -1
View File
@@ -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));
});
@@ -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"`);
});
});
+9 -2
View File
@@ -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<number> {
-119
View File
@@ -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<string> {
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<Result<WorkflowDescriptor, string>> {
let mod: Record<string, unknown>;
try {
const href = pathToFileURL(absoluteTsPath).href;
// Dynamic import required: user workflow source path resolved at add/build time
mod = (await import(href)) as Record<string, unknown>;
} 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<Result<BuildPipelineResult, string>> {
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<ReturnType<typeof Bun.build>>;
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`;
}
+147 -51
View File
@@ -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;
function namedExportDeclExportsRunCallable(named: ExportNamedDeclaration): boolean {
const decl = named.declaration;
if (decl === null || decl === undefined) {
return false;
}
const decl = (stmt as ExportDefaultDeclaration).declaration;
if (
decl.type === "FunctionDeclaration" ||
decl.type === "FunctionExpression" ||
decl.type === "ArrowFunctionExpression"
) {
return true;
if (decl.type === "FunctionDeclaration") {
const id = decl.id;
return id !== null && id !== undefined && id.type === "Identifier" && id.name === "run";
}
if (decl.type === "CallExpression") {
return true;
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<void, string> {
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 }")',
);
}
@@ -20,7 +20,7 @@ function isRoleNext<M extends RoleMeta>(
/**
* 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<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
@@ -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<Result<ExtractedBundleExports, string>> {
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<string, unknown>;
const defaultExport = modRec.default;
if (defaultExport !== undefined) {
return err("workflow bundle must not use default export; export const run instead");
}
const run = modRec.run;
if (typeof run !== "function") {
return err("workflow bundle must export run as a function");
}
const validated = validateWorkflowDescriptor(modRec.descriptor);
if (!validated.ok) {
return err(validated.error);
}
return ok({ run: run as WorkflowFn, descriptor: validated.value });
}
-27
View File
@@ -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");
}
+1 -6
View File
@@ -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,
-108
View File
@@ -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, unknown>): 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, unknown>): 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, unknown>): string | null {
if (record.type !== "object") {
return null;
}
const propsRaw = record.properties;
const requiredRaw = record.required;
const required = new Set<string>(
Array.isArray(requiredRaw) ? requiredRaw.filter((x): x is string => typeof x === "string") : [],
);
if (propsRaw === null || propsRaw === undefined) {
return "Record<string, unknown>";
}
if (typeof propsRaw !== "object" || Array.isArray(propsRaw)) {
return "Record<string, unknown>";
}
const props = propsRaw as Record<string, unknown>;
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<string, unknown>;
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";
}
+2 -2
View File
@@ -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,
+4 -7
View File
@@ -295,16 +295,13 @@ async function main(): Promise<void> {
// Dynamic import required: user bundle path resolved at runtime
const modUnknown: unknown = await import(pathToFileURL(bundlePath).href);
const modRec = modUnknown as Record<string, unknown>;
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<string, ThreadHandle>();
let activeThreads = 0;
+1 -1
View File
@@ -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<string, WorkflowRoleDescriptor>;