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:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
| Concept | What it is |
|
| 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/`. |
|
| **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`. |
|
| **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`. |
|
| **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
|
- Always named exports, never default exports
|
||||||
- One module = one responsibility, filename = purpose
|
- 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
|
## Naming
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ console.log(result);
|
|||||||
|
|
||||||
Do NOT use `await import()` in production code. Always use static top-level `import`.
|
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
|
```ts
|
||||||
// Dynamic import required: user bundle path resolved at runtime
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
|
|||||||
-10
@@ -38,16 +38,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"includes": ["examples/**/*.ts", "packages/workflow/__tests__/fixtures/**/*.ts"],
|
|
||||||
"linter": {
|
|
||||||
"rules": {
|
|
||||||
"style": {
|
|
||||||
"noDefaultExport": "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Monorepo uses **bun workspace**.
|
|||||||
|
|
||||||
## 2. Workflow Physical Implementation
|
## 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
|
```typescript
|
||||||
/** What each yield produces — one role's output. */
|
/** 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:
|
exporting a framework-specific shape:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Example bundle — zero framework dependency
|
// Example bundle — zero framework dependency (named exports only)
|
||||||
export default async function* (input, options) {
|
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);
|
const plan = await callLLM("plan: " + input.prompt);
|
||||||
yield { role: "planner", content: plan, meta: { files: ["src/auth.ts"] } };
|
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: "..." } };
|
yield { role: "coder", content: code, meta: { diff: "..." } };
|
||||||
|
|
||||||
return { returnCode: 0, summary: "Fixed auth bug" };
|
return { returnCode: 0, summary: "Fixed auth bug" };
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**Engine controls the loop**, not the bundle:
|
**Engine controls the loop**, not the bundle:
|
||||||
@@ -104,14 +118,20 @@ any framework types.
|
|||||||
### Constraints
|
### Constraints
|
||||||
|
|
||||||
- Single `.esm.js` file
|
- Single `.esm.js` file
|
||||||
|
- Named exports `run` (callable AsyncGenerator workflow) and `descriptor` (metadata object)
|
||||||
|
- No default export
|
||||||
- No dynamic `import()`
|
- No dynamic `import()`
|
||||||
- All static imports must be Node built-in modules only
|
- 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.
|
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
|
```yaml
|
||||||
description: "Workflow brief introduction"
|
description: "Workflow brief introduction"
|
||||||
@@ -136,9 +156,7 @@ roles:
|
|||||||
type: string
|
type: string
|
||||||
```
|
```
|
||||||
|
|
||||||
Format: `{ description: string, roles: Record<string, { description: string, schema: JSONSchema }> }`
|
Execution uses `run` only; YAML is for tooling and introspection.
|
||||||
|
|
||||||
This file is **not required** for execution.
|
|
||||||
|
|
||||||
## 3. Storage Layout
|
## 3. Storage Layout
|
||||||
|
|
||||||
@@ -148,7 +166,7 @@ All data lives under `~/.uncaged/workflow/`:
|
|||||||
~/.uncaged/workflow/
|
~/.uncaged/workflow/
|
||||||
├── bundles/ # ESM bundles
|
├── bundles/ # ESM bundles
|
||||||
│ ├── C9NMV6V2TQT81.esm.js # Crockford Base32 of XXH64 hash
|
│ ├── 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
|
├── logs/ # Thread data, one folder per bundle hash
|
||||||
│ └── C9NMV6V2TQT81/
|
│ └── C9NMV6V2TQT81/
|
||||||
│ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl
|
│ ├── 01KQXKW18CT8G75T53R8F4G7YG.data.jsonl
|
||||||
@@ -249,7 +267,7 @@ No concurrency control or timeout settings in the registry — those belong to e
|
|||||||
|
|
||||||
| Command | Description |
|
| 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 list` | List registered workflows |
|
||||||
| `uncaged-workflow show <name>` | Show workflow details |
|
| `uncaged-workflow show <name>` | Show workflow details |
|
||||||
| `uncaged-workflow remove <name>` | Remove a workflow |
|
| `uncaged-workflow remove <name>` | Remove a workflow |
|
||||||
@@ -292,9 +310,17 @@ function createRoleModerator<M extends RoleMeta>(
|
|||||||
Usage in a bundle:
|
Usage in a bundle:
|
||||||
|
|
||||||
```typescript
|
```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 },
|
roles: { planner, coder },
|
||||||
moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
|
moderator(ctx) { return ctx.steps.length === 0 ? "planner" : END; },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const greeter: Role<Roles["greeter"]> = async (ctx) => ({
|
|||||||
meta: { greeting: "Hello!" },
|
meta: { greeting: "Hello!" },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default createRoleModerator<Roles>({
|
export const run = createRoleModerator<Roles>({
|
||||||
roles: { greeter },
|
roles: { greeter },
|
||||||
moderator(ctx) {
|
moderator(ctx) {
|
||||||
return ctx.steps.length === 0 ? "greeter" : END;
|
return ctx.steps.length === 0 ? "greeter" : END;
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { ParsedAddArgv } from "../src/add-argv.js";
|
import type { ParsedAddArgv } from "../src/cmd-add.js";
|
||||||
|
|
||||||
export const MINIMAL_DESCRIPTOR_YAML = `description: "fixture"
|
|
||||||
roles: {}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function addCliArgs(name: string, filePath: string): ParsedAddArgv {
|
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 { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
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 { cmdRemove } from "../src/cmd-remove.js";
|
||||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
import { cmdRollback } from "../src/cmd-rollback.js";
|
||||||
import { cmdShow } from "../src/cmd-show.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", () => {
|
describe("cli workflow commands", () => {
|
||||||
let prevEnv: string | undefined;
|
let prevEnv: string | undefined;
|
||||||
@@ -38,9 +40,9 @@ describe("cli workflow commands", () => {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
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(".");
|
fs.existsSync(".");
|
||||||
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
yield { role: "noop", content: input.prompt, meta: { done: true } };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
@@ -48,7 +50,6 @@ export default async function* (input) {
|
|||||||
`,
|
`,
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -87,18 +88,30 @@ export default async function* (input) {
|
|||||||
const bundlePath = join(storageRoot, "bad.esm.js");
|
const bundlePath = join(storageRoot, "bad.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
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",
|
"utf8",
|
||||||
);
|
);
|
||||||
const r = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const r = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(r.ok).toBe(false);
|
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");
|
const bundlePath = join(storageRoot, "solo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`export const run = async function* (input) {
|
||||||
yield { role: "x", content: input.prompt, meta: {} };
|
yield { role: "x", content: input.prompt, meta: {} };
|
||||||
return { returnCode: 0, summary: "ok" };
|
return { returnCode: 0, summary: "ok" };
|
||||||
}
|
}
|
||||||
@@ -107,15 +120,34 @@ export default async function* (input) {
|
|||||||
);
|
);
|
||||||
const r = await cmdAdd(storageRoot, addCliArgs("solo", bundlePath));
|
const r = await cmdAdd(storageRoot, addCliArgs("solo", bundlePath));
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (r.ok) {
|
if (!r.ok) {
|
||||||
return;
|
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 () => {
|
test("add from .esm.js writes yaml from descriptor export", async () => {
|
||||||
const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url));
|
const bundleDir = join(storageRoot, "src");
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("hello", helloTs));
|
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);
|
expect(added.ok).toBe(true);
|
||||||
if (!added.ok) {
|
if (!added.ok) {
|
||||||
return;
|
return;
|
||||||
@@ -125,10 +157,8 @@ export default async function* (input) {
|
|||||||
const esm = await readFile(join(bundles, `${hash}.esm.js`), "utf8");
|
const esm = await readFile(join(bundles, `${hash}.esm.js`), "utf8");
|
||||||
expect(esm.length).toBeGreaterThan(100);
|
expect(esm.length).toBeGreaterThan(100);
|
||||||
const yaml = await readFile(join(bundles, `${hash}.yaml`), "utf8");
|
const yaml = await readFile(join(bundles, `${hash}.yaml`), "utf8");
|
||||||
expect(yaml).toContain("hello world");
|
expect(yaml).toContain("hello world fixture");
|
||||||
const dts = await readFile(join(bundles, `${hash}.d.ts`), "utf8");
|
expect(yaml).toContain("greeter");
|
||||||
expect(dts).toContain("export type Roles");
|
|
||||||
expect(dts).toContain("WorkflowFn");
|
|
||||||
|
|
||||||
const reg = await readWorkflowRegistry(storageRoot);
|
const reg = await readWorkflowRegistry(storageRoot);
|
||||||
expect(reg.ok).toBe(true);
|
expect(reg.ok).toBe(true);
|
||||||
@@ -143,37 +173,64 @@ export default async function* (input) {
|
|||||||
expect(entry.hash).toBe(hash);
|
expect(entry.hash).toBe(hash);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("add from .esm.js with --descriptor uses explicit YAML path", async () => {
|
test("add from .esm.js copies optional sidecar .d.ts", async () => {
|
||||||
const bundleDir = join(storageRoot, "w");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "app.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
const yamlPath = join(bundleDir, "desc.yaml");
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`${fixtureDescriptor}export const run = async function* (input) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
yield { role: "a", content: "x", meta: {} };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
"utf8",
|
"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, {
|
const added = await cmdAdd(storageRoot, {
|
||||||
name: "app",
|
name: "app",
|
||||||
filePath: bundlePath,
|
filePath: bundlePath,
|
||||||
descriptorPath: yamlPath,
|
typesPath: dtsPath,
|
||||||
typesPath: null,
|
|
||||||
});
|
});
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
if (!added.ok) {
|
if (!added.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const yamlStored = await readFile(
|
const dtsStored = await readFile(
|
||||||
join(storageRoot, "bundles", `${added.value.hash}.yaml`),
|
join(storageRoot, "bundles", `${added.value.hash}.d.ts`),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
expect(yamlStored).toContain("fixture");
|
expect(dtsStored).toContain("App");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("add from .esm.js warns when optional .d.ts is missing", async () => {
|
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");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`${fixtureDescriptor}export const run = async function* (input) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
yield { role: "a", content: "x", meta: {} };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -204,18 +260,17 @@ export default async function* (input) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
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: {} };
|
yield { role: "a", content: "v1", meta: {} };
|
||||||
return { returnCode: 0, summary: "v1" };
|
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: {} };
|
yield { role: "a", content: "v2", meta: {} };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
await writeFile(bundlePath, v1, "utf8");
|
await writeFile(bundlePath, v1, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
await new Promise((r) => setTimeout(r, 15));
|
await new Promise((r) => setTimeout(r, 15));
|
||||||
@@ -243,18 +298,17 @@ export default async function* (input) {
|
|||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
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: {} };
|
yield { role: "a", content: "v1", meta: {} };
|
||||||
return { returnCode: 0, summary: "v1" };
|
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: {} };
|
yield { role: "a", content: "v2", meta: {} };
|
||||||
return { returnCode: 0, summary: "v2" };
|
return { returnCode: 0, summary: "v2" };
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
await writeFile(bundlePath, v1, "utf8");
|
await writeFile(bundlePath, v1, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
if (!add1.ok) {
|
if (!add1.ok) {
|
||||||
@@ -292,19 +346,18 @@ export default async function* (input) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`${fixtureDescriptor}export const run = async function* (input) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
yield { role: "a", content: "x", meta: {} };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`${fixtureDescriptor}export const run = async function* (input) {
|
||||||
yield { role: "a", content: "y", meta: {} };
|
yield { role: "a", content: "y", meta: {} };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
@@ -324,14 +377,13 @@ export default async function* (input) {
|
|||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`${fixtureDescriptor}export const run = async function* (input) {
|
||||||
yield { role: "a", content: "x", meta: {} };
|
yield { role: "a", content: "x", meta: {} };
|
||||||
return { returnCode: 0, summary: "x" };
|
return { returnCode: 0, summary: "x" };
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(add1.ok).toBe(true);
|
expect(add1.ok).toBe(true);
|
||||||
if (!add1.ok) {
|
if (!add1.ok) {
|
||||||
@@ -340,7 +392,7 @@ export default async function* (input) {
|
|||||||
const hash1 = add1.value.hash;
|
const hash1 = add1.value.hash;
|
||||||
await writeFile(
|
await writeFile(
|
||||||
bundlePath,
|
bundlePath,
|
||||||
`export default async function* (input) {
|
`${fixtureDescriptor}export const run = async function* (input) {
|
||||||
yield { role: "a", content: "y", meta: {} };
|
yield { role: "a", content: "y", meta: {} };
|
||||||
return { returnCode: 0, summary: "y" };
|
return { returnCode: 0, summary: "y" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,18 @@ import { cmdAdd } from "../src/cmd-add.js";
|
|||||||
import { cmdFork } from "../src/cmd-fork.js";
|
import { cmdFork } from "../src/cmd-fork.js";
|
||||||
import { cmdRun } from "../src/cmd-run.js";
|
import { cmdRun } from "../src/cmd-run.js";
|
||||||
import { pathExists } from "../src/fs-utils.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. */
|
/** 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);
|
const has = (r) => input.steps.some((s) => s.role === r);
|
||||||
if (!has("planner")) {
|
if (!has("planner")) {
|
||||||
yield { role: "planner", content: "p1", meta: { k: "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" };
|
return { returnCode: 0, summary: "done" };
|
||||||
}
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||||
@@ -82,7 +90,6 @@ describe("cli fork", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -133,7 +140,6 @@ describe("cli fork", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -185,7 +191,6 @@ describe("cli fork", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
await writeFile(bundlePath, threeRoleBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
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 { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||||
import { cmdThreads } from "../src/cmd-threads.js";
|
import { cmdThreads } from "../src/cmd-threads.js";
|
||||||
import { pathExists } from "../src/fs-utils.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: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||||
return { returnCode: 0, summary: "done" };
|
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));
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
}
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const cliEntryPath = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
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));
|
await new Promise((r) => setTimeout(r, 600));
|
||||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||||
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
yield { role: "coder", content: "code", meta: { diff: "y" } };
|
||||||
return { returnCode: 0, summary: "done" };
|
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: {} };
|
yield { role: "first", content: "f", meta: {} };
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
yield { role: "second", content: "s", meta: {} };
|
yield { role: "second", content: "s", meta: {} };
|
||||||
return { returnCode: 0, summary: "done" };
|
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));
|
await new Promise((r) => setTimeout(r, 900));
|
||||||
yield { role: "only", content: "x", meta: {} };
|
yield { role: "only", content: "x", meta: {} };
|
||||||
return { returnCode: 0, summary: "done" };
|
return { returnCode: 0, summary: "done" };
|
||||||
}
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
async function countDataJsonlLines(dataPath: string): Promise<number> {
|
||||||
@@ -113,7 +131,6 @@ describe("cli thread commands", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, fastBundleSource, "utf8");
|
await writeFile(bundlePath, fastBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -175,7 +192,6 @@ describe("cli thread commands", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, slowPlannerBundleSource, "utf8");
|
await writeFile(bundlePath, slowPlannerBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -206,7 +222,6 @@ describe("cli thread commands", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, abortablePlannerBundleSource, "utf8");
|
await writeFile(bundlePath, abortablePlannerBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -246,7 +261,6 @@ describe("cli thread commands", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, pauseResumeBundleSource, "utf8");
|
await writeFile(bundlePath, pauseResumeBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -288,7 +302,6 @@ describe("cli thread commands", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, fastBundleSource, "utf8");
|
await writeFile(bundlePath, fastBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
@@ -318,7 +331,6 @@ describe("cli thread commands", () => {
|
|||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
const bundlePath = join(bundleDir, "demo.esm.js");
|
const bundlePath = join(bundleDir, "demo.esm.js");
|
||||||
await writeFile(bundlePath, delayedFirstYieldBundleSource, "utf8");
|
await writeFile(bundlePath, delayedFirstYieldBundleSource, "utf8");
|
||||||
await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8");
|
|
||||||
|
|
||||||
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath));
|
||||||
expect(added.ok).toBe(true);
|
expect(added.ok).toBe(true);
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { parseAddArgv } from "./add-argv.js";
|
|
||||||
import { printCliError, printCliLine, printCliWarn } from "./cli-output.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 { cmdFork, parseForkArgv } from "./cmd-fork.js";
|
||||||
import { cmdHistory } from "./cmd-history.js";
|
import { cmdHistory } from "./cmd-history.js";
|
||||||
import { cmdKill } from "./cmd-kill.js";
|
import { cmdKill } from "./cmd-kill.js";
|
||||||
@@ -19,7 +18,7 @@ import { parseRunArgv } from "./run-argv.js";
|
|||||||
function usage(): string {
|
function usage(): string {
|
||||||
return [
|
return [
|
||||||
"Usage:",
|
"Usage:",
|
||||||
" uncaged-workflow add <name> <file> [--descriptor <path>] [--types <path>]",
|
" uncaged-workflow add <name> <file.esm.js> [--types <path>]",
|
||||||
" uncaged-workflow list",
|
" uncaged-workflow list",
|
||||||
" uncaged-workflow show <name>",
|
" uncaged-workflow show <name>",
|
||||||
" uncaged-workflow remove <name>",
|
" uncaged-workflow remove <name>",
|
||||||
|
|||||||
@@ -2,42 +2,110 @@ import { readFile, stat } from "node:fs/promises";
|
|||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildWorkflowFromTypeScript,
|
|
||||||
err,
|
err,
|
||||||
|
extractBundleExports,
|
||||||
hashWorkflowBundleBytes,
|
hashWorkflowBundleBytes,
|
||||||
ok,
|
ok,
|
||||||
type Result,
|
type Result,
|
||||||
readWorkflowRegistry,
|
readWorkflowRegistry,
|
||||||
registerWorkflowVersion,
|
registerWorkflowVersion,
|
||||||
|
stringifyWorkflowDescriptor,
|
||||||
validateWorkflowBundle,
|
validateWorkflowBundle,
|
||||||
writeWorkflowRegistry,
|
writeWorkflowRegistry,
|
||||||
} from "@uncaged/workflow";
|
} from "@uncaged/workflow";
|
||||||
|
|
||||||
import type { ParsedAddArgv } from "./add-argv.js";
|
|
||||||
import { storeWorkflowBundleArtifacts } from "./bundle-store.js";
|
import { storeWorkflowBundleArtifacts } from "./bundle-store.js";
|
||||||
import { validateCliWorkflowName } from "./workflow-name.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 = {
|
export type CmdAddSuccess = {
|
||||||
hash: string;
|
hash: string;
|
||||||
warnings: ReadonlyArray<string>;
|
warnings: ReadonlyArray<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isTypeScriptWorkflow(path: string): boolean {
|
|
||||||
return path.endsWith(".ts");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEsmBundle(path: string): boolean {
|
function isEsmBundle(path: string): boolean {
|
||||||
return path.endsWith(".esm.js");
|
return path.endsWith(".esm.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultDescriptorPath(bundlePath: string): string {
|
|
||||||
return bundlePath.replace(/\.esm\.js$/i, ".yaml");
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultTypesPath(bundlePath: string): string {
|
function defaultTypesPath(bundlePath: string): string {
|
||||||
return bundlePath.replace(/\.esm\.js$/i, ".d.ts");
|
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(
|
async function registerHash(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -56,125 +124,31 @@ async function registerHash(
|
|||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFromTypeScript(
|
async function resolveOptionalTypes(
|
||||||
storageRoot: string,
|
typesPathFlag: string | null,
|
||||||
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,
|
|
||||||
resolvedBundlePath: string,
|
resolvedBundlePath: string,
|
||||||
): Promise<Result<{ yamlText: string; dtsText: string | null; warnings: string[] }, string>> {
|
): Promise<Result<{ dtsText: string | null; warnings: string[] }, string>> {
|
||||||
const warnings: 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;
|
let dtsText: string | null = null;
|
||||||
if (args.typesPath !== null) {
|
|
||||||
const typesResolved = resolve(args.typesPath);
|
if (typesPathFlag !== null) {
|
||||||
|
const typesResolved = resolve(typesPathFlag);
|
||||||
try {
|
try {
|
||||||
dtsText = await readFile(typesResolved, "utf8");
|
dtsText = await readFile(typesResolved, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
return err(`types file not found: ${typesResolved}`);
|
return err(`types file not found: ${typesResolved}`);
|
||||||
}
|
}
|
||||||
} else {
|
return ok({ dtsText, warnings });
|
||||||
|
}
|
||||||
|
|
||||||
const typesDefault = defaultTypesPath(resolvedBundlePath);
|
const typesDefault = defaultTypesPath(resolvedBundlePath);
|
||||||
try {
|
try {
|
||||||
dtsText = await readFile(typesDefault, "utf8");
|
dtsText = await readFile(typesDefault, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`);
|
warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ yamlText, dtsText, warnings });
|
return ok({ 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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cmdAdd(
|
export async function cmdAdd(
|
||||||
@@ -194,15 +168,66 @@ export async function cmdAdd(
|
|||||||
return err(`file not found: ${args.filePath}`);
|
return err(`file not found: ${args.filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTypeScriptWorkflow(resolvedPath)) {
|
if (resolvedPath.endsWith(".ts")) {
|
||||||
return addFromTypeScript(storageRoot, args.name, resolvedPath);
|
return err("build your .ts file first, then add the .esm.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEsmBundle(resolvedPath)) {
|
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 {
|
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";
|
import { validateWorkflowBundle } from "../src/bundle-validator.js";
|
||||||
|
|
||||||
|
const minimalDescriptor = `export const descriptor = { description: "x", roles: {} };
|
||||||
|
`;
|
||||||
|
|
||||||
describe("validateWorkflowBundle", () => {
|
describe("validateWorkflowBundle", () => {
|
||||||
test("accepts export { local as default } when local is a call expression result", () => {
|
test("accepts export { local as run } when local is a call expression result", () => {
|
||||||
const source = `var wf = createFn({});
|
const source = `${minimalDescriptor}var wf = createFn({});
|
||||||
export { wf as default };
|
export { wf as run };
|
||||||
`;
|
`;
|
||||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts minimal valid builtin-only bundle", () => {
|
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(".");
|
fs.existsSync(".");
|
||||||
return { returnCode: 0, summary: input.prompt };
|
return { returnCode: 0, summary: input.prompt };
|
||||||
}
|
};
|
||||||
`;
|
`;
|
||||||
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
@@ -26,27 +29,15 @@ export default async function* (input) {
|
|||||||
test("rejects wrong filename suffix", () => {
|
test("rejects wrong filename suffix", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.js",
|
filePath: "/tmp/w.js",
|
||||||
source:
|
source: `${minimalDescriptor}export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n`,
|
||||||
"export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n",
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects default export that is not a callable bundle shape", () => {
|
test("rejects default export", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.esm.js",
|
filePath: "/tmp/w.esm.js",
|
||||||
source: 'export default { name: "x", roles: {}, moderator() { return "__end__"; } };\n',
|
source: `${minimalDescriptor}export default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n`,
|
||||||
});
|
|
||||||
expect(r.ok).toBe(false);
|
|
||||||
if (!r.ok) {
|
|
||||||
expect(r.error).toContain("default export must be a function");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects missing default export", () => {
|
|
||||||
const r = validateWorkflowBundle({
|
|
||||||
filePath: "/tmp/w.esm.js",
|
|
||||||
source: "export const x = 1;\n",
|
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
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", () => {
|
test("rejects non-builtin imports", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.esm.js",
|
filePath: "/tmp/w.esm.js",
|
||||||
source:
|
source: `${minimalDescriptor}import x from "some-package";
|
||||||
'import x from "some-package";\nexport default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n',
|
export const run = async function* (input) { return { returnCode: 0, summary: input.prompt }; }
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -66,8 +95,8 @@ export default async function* (input) {
|
|||||||
test("rejects dynamic import", () => {
|
test("rejects dynamic import", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.esm.js",
|
filePath: "/tmp/w.esm.js",
|
||||||
source:
|
source: `${minimalDescriptor}export const run = async function* (input) { await import("fs"); return { returnCode: 0, summary: input.prompt }; }
|
||||||
'export default async function* (input) { await import("fs"); return { returnCode: 0, summary: input.prompt }; }\n',
|
`,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -78,8 +107,8 @@ export default async function* (input) {
|
|||||||
test("rejects require()", () => {
|
test("rejects require()", () => {
|
||||||
const r = validateWorkflowBundle({
|
const r = validateWorkflowBundle({
|
||||||
filePath: "/tmp/w.esm.js",
|
filePath: "/tmp/w.esm.js",
|
||||||
source:
|
source: `${minimalDescriptor}export const run = async function* (input) { require("fs"); return { returnCode: 0, summary: input.prompt }; }
|
||||||
'export default async function* (input) { require("fs"); return { returnCode: 0, summary: input.prompt }; }\n',
|
`,
|
||||||
});
|
});
|
||||||
expect(r.ok).toBe(false);
|
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;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -17,7 +17,9 @@ describe("hashWorkflowBundleBytes", () => {
|
|||||||
test("stable for identical content", () => {
|
test("stable for identical content", () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(
|
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));
|
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"`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,14 @@ import { join } from "node:path";
|
|||||||
|
|
||||||
import { getWorkerHostScriptPath } from "../src/worker-entry-path.js";
|
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);
|
const has = (r) => input.steps.some((s) => s.role === r);
|
||||||
if (!has("planner")) {
|
if (!has("planner")) {
|
||||||
yield { role: "planner", content: "p", meta: { plan: input.prompt } };
|
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" } };
|
yield { role: "coder", content: "c", meta: { diff: "y" } };
|
||||||
}
|
}
|
||||||
return { returnCode: 0, summary: "completed: moderator returned END" };
|
return { returnCode: 0, summary: "completed: moderator returned END" };
|
||||||
}
|
};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
async function readReadyPort(child: import("node:child_process").ChildProcess): Promise<number> {
|
async function readReadyPort(child: import("node:child_process").ChildProcess): Promise<number> {
|
||||||
|
|||||||
@@ -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`;
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { isBuiltin } from "node:module";
|
|||||||
import type {
|
import type {
|
||||||
CallExpression,
|
CallExpression,
|
||||||
ExportAllDeclaration,
|
ExportAllDeclaration,
|
||||||
ExportDefaultDeclaration,
|
|
||||||
ExportNamedDeclaration,
|
ExportNamedDeclaration,
|
||||||
ExportSpecifier,
|
ExportSpecifier,
|
||||||
FunctionDeclaration,
|
FunctionDeclaration,
|
||||||
@@ -69,54 +68,34 @@ function walkAst(node: Node, visit: (n: Node) => void): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportSpecifierIsDefaultReExport(spec: ExportSpecifier): boolean {
|
function exportSpecifierExportedName(spec: ExportSpecifier): string | null {
|
||||||
return spec.exported.type === "Identifier" && spec.exported.name === "default";
|
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) {
|
if (named.source !== null && named.source !== undefined) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return named.specifiers.some(
|
return named.specifiers.some(
|
||||||
(spec) => spec.type === "ExportSpecifier" && exportSpecifierIsDefaultReExport(spec),
|
(spec) => spec.type === "ExportSpecifier" && exportSpecifierExportedName(spec) === "default",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function programHasDefaultExport(body: readonly Node[]): boolean {
|
function programUsesDefaultExport(program: Program): boolean {
|
||||||
for (const stmt of body) {
|
for (const stmt of program.body) {
|
||||||
if (stmt.type === "ExportDefaultDeclaration") {
|
if (stmt.type === "ExportDefaultDeclaration") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclarationOffersDefault(stmt)) {
|
if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclReExportsDefault(stmt)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
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 {
|
function bindingInitializerIsCallable(init: Node): boolean {
|
||||||
return (
|
return (
|
||||||
init.type === "FunctionExpression" ||
|
init.type === "FunctionExpression" ||
|
||||||
@@ -157,32 +136,142 @@ function programDeclaresCallableExportBinding(program: Program, name: string): b
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultExportDeclarationIsCallable(program: Program): boolean {
|
function namedExportDeclExportsRunCallable(named: ExportNamedDeclaration): boolean {
|
||||||
for (const stmt of program.body) {
|
const decl = named.declaration;
|
||||||
if (stmt.type !== "ExportDefaultDeclaration") {
|
if (decl === null || decl === undefined) {
|
||||||
continue;
|
return false;
|
||||||
}
|
}
|
||||||
const decl = (stmt as ExportDefaultDeclaration).declaration;
|
if (decl.type === "FunctionDeclaration") {
|
||||||
if (
|
const id = decl.id;
|
||||||
decl.type === "FunctionDeclaration" ||
|
return id !== null && id !== undefined && id.type === "Identifier" && id.name === "run";
|
||||||
decl.type === "FunctionExpression" ||
|
|
||||||
decl.type === "ArrowFunctionExpression"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
if (decl.type === "CallExpression") {
|
if (decl.type === "VariableDeclaration") {
|
||||||
return true;
|
return variableDeclarationBindsCallableName(decl, "run");
|
||||||
}
|
}
|
||||||
return false;
|
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) {
|
if (exportBinding !== null) {
|
||||||
return programDeclaresCallableExportBinding(program, exportBinding);
|
return programDeclaresCallableExportBinding(program, exportBinding);
|
||||||
}
|
}
|
||||||
return false;
|
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 {
|
function stringLiteralModuleSpecifier(src: Node): string | null {
|
||||||
if (src.type !== "Literal" || typeof src.value !== "string") {
|
if (src.type !== "Literal" || typeof src.value !== "string") {
|
||||||
return null;
|
return null;
|
||||||
@@ -263,8 +352,8 @@ function bundleConstraintViolationForNode(node: Node): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate RFC-001 bundle rules: single-file ESM shape, default export,
|
* Validate RFC-001 bundle rules: single-file ESM shape, named exports `run` + `descriptor`,
|
||||||
* no dynamic `import()`, static imports restricted to Node builtins.
|
* no default export, no dynamic `import()`, static imports restricted to Node builtins.
|
||||||
*/
|
*/
|
||||||
export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result<void, string> {
|
export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result<void, string> {
|
||||||
if (!endsWithEsmJs(input.filePath)) {
|
if (!endsWithEsmJs(input.filePath)) {
|
||||||
@@ -288,13 +377,20 @@ export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
const program = ast as Program;
|
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(
|
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
|
* 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>(
|
export function createRoleModerator<M extends RoleMeta>(
|
||||||
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
|
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 });
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
@@ -5,10 +5,6 @@ export {
|
|||||||
encodeCrockfordBase32Bits,
|
encodeCrockfordBase32Bits,
|
||||||
encodeUint64AsCrockford,
|
encodeUint64AsCrockford,
|
||||||
} from "./base32.js";
|
} from "./base32.js";
|
||||||
export {
|
|
||||||
type BuildPipelineResult,
|
|
||||||
buildWorkflowFromTypeScript,
|
|
||||||
} from "./build-pipeline.js";
|
|
||||||
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
||||||
export { createRoleModerator } from "./create-role-moderator.js";
|
export { createRoleModerator } from "./create-role-moderator.js";
|
||||||
export {
|
export {
|
||||||
@@ -17,6 +13,7 @@ export {
|
|||||||
executeThread,
|
executeThread,
|
||||||
type PrefilledDiskStep,
|
type PrefilledDiskStep,
|
||||||
} from "./engine.js";
|
} from "./engine.js";
|
||||||
|
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
|
||||||
export {
|
export {
|
||||||
buildForkPlan,
|
buildForkPlan,
|
||||||
type ForkHistoricalStep,
|
type ForkHistoricalStep,
|
||||||
@@ -26,9 +23,7 @@ export {
|
|||||||
selectForkHistoricalSteps,
|
selectForkHistoricalSteps,
|
||||||
} from "./fork-thread.js";
|
} from "./fork-thread.js";
|
||||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||||
export { generateWorkflowBundleTypes } from "./generate-types.js";
|
|
||||||
export { hashWorkflowBundleBytes } from "./hash.js";
|
export { hashWorkflowBundleBytes } from "./hash.js";
|
||||||
export { jsonSchemaToTypeString } from "./json-schema-to-ts.js";
|
|
||||||
export {
|
export {
|
||||||
type CreateLoggerOptions,
|
type CreateLoggerOptions,
|
||||||
createLogger,
|
createLogger,
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
|
||||||
@@ -24,13 +24,13 @@ export type ThreadInput = {
|
|||||||
steps: RoleOutput[];
|
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 = {
|
export type WorkflowFnOptions = {
|
||||||
isDryRun: boolean;
|
isDryRun: boolean;
|
||||||
maxRounds: number;
|
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 = (
|
export type WorkflowFn = (
|
||||||
input: ThreadInput,
|
input: ThreadInput,
|
||||||
options: WorkflowFnOptions,
|
options: WorkflowFnOptions,
|
||||||
|
|||||||
@@ -295,16 +295,13 @@ async function main(): Promise<void> {
|
|||||||
// Dynamic import required: user bundle path resolved at runtime
|
// Dynamic import required: user bundle path resolved at runtime
|
||||||
const modUnknown: unknown = await import(pathToFileURL(bundlePath).href);
|
const modUnknown: unknown = await import(pathToFileURL(bundlePath).href);
|
||||||
const modRec = modUnknown as Record<string, unknown>;
|
const modRec = modUnknown as Record<string, unknown>;
|
||||||
const defaultExport = modRec.default;
|
const runExport = modRec.run;
|
||||||
if (!isWorkflowFnLike(defaultExport)) {
|
if (!isWorkflowFnLike(runExport)) {
|
||||||
bootLog(
|
bootLog("T4BW9YJX", "workflow bundle must export run as a function (AsyncGenerator workflow)");
|
||||||
"T4BW9YJX",
|
|
||||||
"workflow bundle default export must be a function (AsyncGenerator workflow)",
|
|
||||||
);
|
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workflowFn = defaultExport;
|
const workflowFn = runExport;
|
||||||
|
|
||||||
const threads = new Map<string, ThreadHandle>();
|
const threads = new Map<string, ThreadHandle>();
|
||||||
let activeThreads = 0;
|
let activeThreads = 0;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type WorkflowRoleDescriptor = {
|
|||||||
schema: WorkflowRoleSchema;
|
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 = {
|
export type WorkflowDescriptor = {
|
||||||
description: string;
|
description: string;
|
||||||
roles: Record<string, WorkflowRoleDescriptor>;
|
roles: Record<string, WorkflowRoleDescriptor>;
|
||||||
|
|||||||
Reference in New Issue
Block a user