diff --git a/examples/hello-world.ts b/examples/hello-world.ts new file mode 100644 index 0000000..6c7489a --- /dev/null +++ b/examples/hello-world.ts @@ -0,0 +1,31 @@ +import { createRoleModerator, END, type Role } from "@uncaged/workflow"; + +type Roles = { + greeter: { greeting: string }; +}; + +export const descriptor = { + description: "A simple hello world workflow", + roles: { + greeter: { + description: "Generates a greeting", + schema: { + type: "object", + properties: { greeting: { type: "string" } }, + required: ["greeting"], + }, + }, + }, +}; + +const greeter: Role = async (ctx) => ({ + content: "Hello, " + ctx.start.content, + meta: { greeting: "Hello!" }, +}); + +export default createRoleModerator({ + roles: { greeter }, + moderator(ctx) { + return ctx.steps.length === 0 ? "greeter" : END; + }, +}); diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 0000000..7cca085 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,8 @@ +{ + "name": "@uncaged/workflow-examples", + "private": true, + "type": "module", + "dependencies": { + "@uncaged/workflow": "workspace:*" + } +} diff --git a/package.json b/package.json index e79f496..2e00e7a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "@uncaged/workflow-monorepo", "private": true, "workspaces": [ - "packages/*" + "packages/*", + "examples" ], "scripts": { "build": "bun run --filter '*' build", diff --git a/packages/cli-workflow/__tests__/bundle-fixture.ts b/packages/cli-workflow/__tests__/bundle-fixture.ts new file mode 100644 index 0000000..8e99941 --- /dev/null +++ b/packages/cli-workflow/__tests__/bundle-fixture.ts @@ -0,0 +1,9 @@ +import type { ParsedAddArgv } from "../src/add-argv.js"; + +export const MINIMAL_DESCRIPTOR_YAML = `description: "fixture" +roles: {} +`; + +export function addCliArgs(name: string, filePath: string): ParsedAddArgv { + return { name, filePath, descriptorPath: null, typesPath: null }; +} diff --git a/packages/cli-workflow/__tests__/commands.test.ts b/packages/cli-workflow/__tests__/commands.test.ts index fcdaf40..301cbf8 100644 --- a/packages/cli-workflow/__tests__/commands.test.ts +++ b/packages/cli-workflow/__tests__/commands.test.ts @@ -2,9 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow"; +import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; import { cmdAdd } from "../src/cmd-add.js"; import { cmdHistory } from "../src/cmd-history.js"; import { cmdList, formatListLines } from "../src/cmd-list.js"; @@ -47,8 +49,9 @@ export default async function* (input) { `, "utf8", ); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); const listed = await cmdList(storageRoot); @@ -88,10 +91,83 @@ export default async function* (input) { 'import x from "./local";\nexport default async function* (input) { return { returnCode: 0, summary: input.prompt }; }\n', "utf8", ); - const r = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const r = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(r.ok).toBe(false); }); + test("add rejects .esm.js without companion YAML", async () => { + const bundlePath = join(storageRoot, "solo.esm.js"); + await writeFile( + bundlePath, + `export default async function* (input) { + yield { role: "x", content: input.prompt, meta: {} }; + return { returnCode: 0, summary: "ok" }; +} +`, + "utf8", + ); + const r = await cmdAdd(storageRoot, addCliArgs("solo", bundlePath)); + expect(r.ok).toBe(false); + if (r.ok) { + return; + } + expect(r.error).toContain("descriptor YAML not found"); + }); + + test("add from .ts builds bundle + yaml + d.ts and registers hash", async () => { + const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url)); + const added = await cmdAdd(storageRoot, addCliArgs("hello", helloTs)); + expect(added.ok).toBe(true); + if (!added.ok) { + return; + } + const { hash } = added.value; + const bundles = join(storageRoot, "bundles"); + const esm = await readFile(join(bundles, `${hash}.esm.js`), "utf8"); + expect(esm.length).toBeGreaterThan(100); + const yaml = await readFile(join(bundles, `${hash}.yaml`), "utf8"); + expect(yaml).toContain("hello world"); + const dts = await readFile(join(bundles, `${hash}.d.ts`), "utf8"); + expect(dts).toContain("export type Roles"); + expect(dts).toContain("WorkflowFn"); + + const reg = await readWorkflowRegistry(storageRoot); + expect(reg.ok).toBe(true); + if (!reg.ok) { + return; + } + const entry = getRegisteredWorkflow(reg.value, "hello"); + expect(entry).not.toBeNull(); + if (entry === null) { + return; + } + expect(entry.hash).toBe(hash); + }); + + test("add from .esm.js warns when optional .d.ts is missing", async () => { + const bundleDir = join(storageRoot, "src"); + await mkdir(bundleDir, { recursive: true }); + const bundlePath = join(bundleDir, "demo.esm.js"); + await writeFile( + bundlePath, + `export default async function* (input) { + yield { role: "a", content: "x", meta: {} }; + return { returnCode: 0, summary: "x" }; +} +`, + "utf8", + ); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); + + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); + expect(added.ok).toBe(true); + if (!added.ok) { + return; + } + expect(added.value.warnings.length).toBe(1); + expect(added.value.warnings[0]).toContain("demo.d.ts"); + }); + test("history lists current + prior versions sorted by time descending", async () => { const bundleDir = join(storageRoot, "src"); await mkdir(bundleDir, { recursive: true }); @@ -107,11 +183,12 @@ export default async function* (input) { } `; await writeFile(bundlePath, v1, "utf8"); - const add1 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); + const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); await new Promise((r) => setTimeout(r, 15)); await writeFile(bundlePath, v2, "utf8"); - const add2 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const add2 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add2.ok).toBe(true); const hist = await cmdHistory(storageRoot, "solve-issue"); @@ -145,14 +222,15 @@ export default async function* (input) { } `; await writeFile(bundlePath, v1, "utf8"); - const add1 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); + const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); if (!add1.ok) { return; } const hash1 = add1.value.hash; await writeFile(bundlePath, v2, "utf8"); - const add2 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const add2 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add2.ok).toBe(true); if (!add2.ok) { return; @@ -189,7 +267,8 @@ export default async function* (input) { `, "utf8", ); - const add1 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); + const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); await writeFile( bundlePath, @@ -200,7 +279,7 @@ export default async function* (input) { `, "utf8", ); - const add2 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const add2 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add2.ok).toBe(true); const bad = await cmdRollback(storageRoot, "solve-issue", "0000000000000"); @@ -220,7 +299,8 @@ export default async function* (input) { `, "utf8", ); - const add1 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); + const add1 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add1.ok).toBe(true); if (!add1.ok) { return; @@ -235,7 +315,7 @@ export default async function* (input) { `, "utf8", ); - const add2 = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const add2 = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(add2.ok).toBe(true); if (!add2.ok) { return; diff --git a/packages/cli-workflow/__tests__/fork-cli.test.ts b/packages/cli-workflow/__tests__/fork-cli.test.ts index c0e4b3a..1b5d345 100644 --- a/packages/cli-workflow/__tests__/fork-cli.test.ts +++ b/packages/cli-workflow/__tests__/fork-cli.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; import { cmdAdd } from "../src/cmd-add.js"; import { cmdFork } from "../src/cmd-fork.js"; import { cmdRun } from "../src/cmd-run.js"; @@ -82,8 +83,9 @@ describe("cli fork", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, threeRoleBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -132,8 +134,9 @@ describe("cli fork", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, threeRoleBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -183,8 +186,9 @@ describe("cli fork", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, threeRoleBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; diff --git a/packages/cli-workflow/__tests__/thread-cli.test.ts b/packages/cli-workflow/__tests__/thread-cli.test.ts index ad9be4f..f75f46e 100644 --- a/packages/cli-workflow/__tests__/thread-cli.test.ts +++ b/packages/cli-workflow/__tests__/thread-cli.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js"; import { cmdAdd } from "../src/cmd-add.js"; import { cmdKill } from "../src/cmd-kill.js"; import { cmdPause } from "../src/cmd-pause.js"; @@ -113,8 +114,9 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, fastBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -174,8 +176,9 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, slowPlannerBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -204,8 +207,9 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, abortablePlannerBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -243,8 +247,9 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, pauseResumeBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -284,8 +289,9 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, fastBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; @@ -313,8 +319,9 @@ describe("cli thread commands", () => { await mkdir(bundleDir, { recursive: true }); const bundlePath = join(bundleDir, "demo.esm.js"); await writeFile(bundlePath, delayedFirstYieldBundleSource, "utf8"); + await writeFile(join(bundleDir, "demo.yaml"), MINIMAL_DESCRIPTOR_YAML, "utf8"); - const added = await cmdAdd(storageRoot, "solve-issue", bundlePath); + const added = await cmdAdd(storageRoot, addCliArgs("solve-issue", bundlePath)); expect(added.ok).toBe(true); if (!added.ok) { return; diff --git a/packages/cli-workflow/src/add-argv.ts b/packages/cli-workflow/src/add-argv.ts new file mode 100644 index 0000000..5e684a5 --- /dev/null +++ b/packages/cli-workflow/src/add-argv.ts @@ -0,0 +1,60 @@ +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; +}; + +export function parseAddArgv(argv: string[]): Result { + let name: string | undefined; + let filePath: string | undefined; + let descriptorPath: string | null = null; + let typesPath: string | null = null; + + let i = 0; + while (i < argv.length) { + const tok = argv[i]; + if (tok === "--descriptor") { + const value = argv[i + 1]; + if (value === undefined || value.startsWith("--")) { + return err("missing value for --descriptor"); + } + descriptorPath = value; + i += 2; + continue; + } + if (tok === "--types") { + const value = argv[i + 1]; + if (value === undefined || value.startsWith("--")) { + return err("missing value for --types"); + } + typesPath = value; + i += 2; + continue; + } + if (tok !== undefined && tok.startsWith("--")) { + return err(`unknown add flag: ${tok}`); + } + if (tok === undefined) { + break; + } + if (name === undefined) { + name = tok; + } else if (filePath === undefined) { + filePath = tok; + } else { + return err("too many arguments"); + } + i += 1; + } + + if (name === undefined || name === "" || filePath === undefined || filePath === "") { + return err("add requires "); + } + + return ok({ name, filePath, descriptorPath, typesPath }); +} diff --git a/packages/cli-workflow/src/bundle-store.ts b/packages/cli-workflow/src/bundle-store.ts index a376127..501bb28 100644 --- a/packages/cli-workflow/src/bundle-store.ts +++ b/packages/cli-workflow/src/bundle-store.ts @@ -1,4 +1,4 @@ -import { copyFile, mkdir, readFile, stat } from "node:fs/promises"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { err, ok, type Result } from "@uncaged/workflow"; @@ -12,28 +12,39 @@ async function pathExists(path: string): Promise { } } -export async function storeWorkflowBundleCopy( - storageRoot: string, - hash: string, - resolvedSourcePath: string, - sourceText: string, -): Promise> { - const bundlesDir = join(storageRoot, "bundles"); - const destPath = join(bundlesDir, `${hash}.esm.js`); +export type BundleFileSource = + | { kind: "text"; text: string } + | { kind: "path"; path: string }; +export type WorkflowBundleStoreInput = { + esmJs: BundleFileSource; + yaml: BundleFileSource; + dts: BundleFileSource | null; +}; + +async function resolveSourceText(src: BundleFileSource): Promise> { + if (src.kind === "text") { + return ok(src.text); + } try { - await mkdir(bundlesDir, { recursive: true }); + return ok(await readFile(src.path, "utf8")); } catch (e) { const message = e instanceof Error ? e.message : String(e); - return err(`failed to store bundle: ${message}`); + return err(`failed to read bundle artifact: ${message}`); } +} +async function ensureMatchingOrWrite( + destPath: string, + text: string, + label: string, +): Promise> { if (!(await pathExists(destPath))) { try { - await copyFile(resolvedSourcePath, destPath); + await writeFile(destPath, text, "utf8"); } catch (e) { const message = e instanceof Error ? e.message : String(e); - return err(`failed to store bundle: ${message}`); + return err(`failed to write ${label}: ${message}`); } return ok(undefined); } @@ -43,10 +54,67 @@ export async function storeWorkflowBundleCopy( existing = await readFile(destPath, "utf8"); } catch (e) { const message = e instanceof Error ? e.message : String(e); - return err(`failed to store bundle: ${message}`); + return err(`failed to read existing ${label}: ${message}`); } - if (existing !== sourceText) { - return err(`bundle hash ${hash} already exists with different contents; refusing to overwrite`); + if (existing !== text) { + return err( + `${label} for this hash already exists with different contents; refusing to overwrite`, + ); } return ok(undefined); } + +/** Store `.esm.js`, `.yaml`, and optional `.d.ts` under `bundles/` keyed by hash. */ +export async function storeWorkflowBundleArtifacts( + storageRoot: string, + hash: string, + input: WorkflowBundleStoreInput, +): Promise> { + const bundlesDir = join(storageRoot, "bundles"); + try { + await mkdir(bundlesDir, { recursive: true }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to store bundle: ${message}`); + } + + const esmText = await resolveSourceText(input.esmJs); + if (!esmText.ok) { + return esmText; + } + const yamlText = await resolveSourceText(input.yaml); + if (!yamlText.ok) { + return yamlText; + } + + let dtsText: string | null = null; + if (input.dts !== null) { + const dtsResolved = await resolveSourceText(input.dts); + if (!dtsResolved.ok) { + return dtsResolved; + } + dtsText = dtsResolved.value; + } + + const destEsm = join(bundlesDir, `${hash}.esm.js`); + const destYaml = join(bundlesDir, `${hash}.yaml`); + const destDts = join(bundlesDir, `${hash}.d.ts`); + + const w1 = await ensureMatchingOrWrite(destEsm, esmText.value, "bundle"); + if (!w1.ok) { + return w1; + } + const w2 = await ensureMatchingOrWrite(destYaml, yamlText.value, "descriptor"); + if (!w2.ok) { + return w2; + } + + if (dtsText !== null) { + const w3 = await ensureMatchingOrWrite(destDts, dtsText, "types"); + if (!w3.ok) { + return w3; + } + } + + return ok(undefined); +} diff --git a/packages/cli-workflow/src/cli-dispatch.ts b/packages/cli-workflow/src/cli-dispatch.ts index b35d004..abe8c9e 100644 --- a/packages/cli-workflow/src/cli-dispatch.ts +++ b/packages/cli-workflow/src/cli-dispatch.ts @@ -1,4 +1,5 @@ -import { printCliError, printCliLine } from "./cli-output.js"; +import { parseAddArgv } from "./add-argv.js"; +import { printCliError, printCliLine, printCliWarn } from "./cli-output.js"; import { cmdAdd, formatAddSuccess } from "./cmd-add.js"; import { cmdFork, parseForkArgv } from "./cmd-fork.js"; import { cmdHistory } from "./cmd-history.js"; @@ -18,7 +19,7 @@ import { parseRunArgv } from "./run-argv.js"; function usage(): string { return [ "Usage:", - " uncaged-workflow add ", + " uncaged-workflow add [--descriptor ] [--types ]", " uncaged-workflow list", " uncaged-workflow show ", " uncaged-workflow remove ", @@ -37,18 +38,20 @@ function usage(): string { } async function dispatchAdd(storageRoot: string, argv: string[]): Promise { - const name = argv[0]; - const file = argv[1]; - if (name === undefined || file === undefined || argv.length > 2) { - printCliError(`${usage()}\n\nerror: add requires `); + const parsed = parseAddArgv(argv); + if (!parsed.ok) { + printCliError(`${usage()}\n\nerror: ${parsed.error}`); return 1; } - const result = await cmdAdd(storageRoot, name, file); + const result = await cmdAdd(storageRoot, parsed.value); if (!result.ok) { printCliError(result.error); return 1; } - printCliLine(formatAddSuccess(name, file, result.value.hash)); + for (const w of result.value.warnings) { + printCliWarn(w); + } + printCliLine(formatAddSuccess(parsed.value.name, parsed.value.filePath, result.value.hash)); return 0; } diff --git a/packages/cli-workflow/src/cli-output.ts b/packages/cli-workflow/src/cli-output.ts index e41fc33..bf9a769 100644 --- a/packages/cli-workflow/src/cli-output.ts +++ b/packages/cli-workflow/src/cli-output.ts @@ -7,3 +7,8 @@ export function printCliError(line: string): void { // biome-ignore lint/suspicious/noConsole: CLI user-facing errors console.error(line); } + +export function printCliWarn(line: string): void { + // biome-ignore lint/suspicious/noConsole: CLI user-facing warnings + console.warn(line); +} diff --git a/packages/cli-workflow/src/cmd-add.ts b/packages/cli-workflow/src/cmd-add.ts index c4d2df0..9f1f0d0 100644 --- a/packages/cli-workflow/src/cmd-add.ts +++ b/packages/cli-workflow/src/cmd-add.ts @@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { + buildWorkflowFromTypeScript, err, hashWorkflowBundleBytes, ok, @@ -12,25 +13,97 @@ import { writeWorkflowRegistry, } from "@uncaged/workflow"; -import { storeWorkflowBundleCopy } from "./bundle-store.js"; +import type { ParsedAddArgv } from "./add-argv.js"; +import { storeWorkflowBundleArtifacts } from "./bundle-store.js"; import { validateCliWorkflowName } from "./workflow-name.js"; +export type CmdAddSuccess = { + hash: string; + warnings: ReadonlyArray; +}; + +function isTypeScriptWorkflow(path: string): boolean { + return path.endsWith(".ts"); +} + +function isEsmBundle(path: string): boolean { + return path.endsWith(".esm.js"); +} + +function defaultDescriptorPath(bundlePath: string): string { + return bundlePath.replace(/\.esm\.js$/i, ".yaml"); +} + +function defaultTypesPath(bundlePath: string): string { + return bundlePath.replace(/\.esm\.js$/i, ".d.ts"); +} + +async function registerHash( + storageRoot: string, + name: string, + hash: string, +): Promise> { + const reg = await readWorkflowRegistry(storageRoot); + if (!reg.ok) { + return err(reg.error.message); + } + + const next = registerWorkflowVersion(reg.value, name, hash, Date.now()); + const written = await writeWorkflowRegistry(storageRoot, next); + if (!written.ok) { + return err(written.error.message); + } + return ok(undefined); +} + export async function cmdAdd( storageRoot: string, - name: string, - filePath: string, -): Promise> { - const nameOk = validateCliWorkflowName(name); + args: ParsedAddArgv, +): Promise> { + const nameOk = validateCliWorkflowName(args.name); if (!nameOk.ok) { return nameOk; } let resolvedPath: string; try { - resolvedPath = resolve(filePath); + resolvedPath = resolve(args.filePath); await stat(resolvedPath); } catch { - return err(`bundle file not found: ${filePath}`); + return err(`file not found: ${args.filePath}`); + } + + const warnings: string[] = []; + + if (isTypeScriptWorkflow(resolvedPath)) { + const built = await buildWorkflowFromTypeScript(resolvedPath); + 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, args.name, hash); + if (!regResult.ok) { + return regResult; + } + + return ok({ hash, warnings }); + } + + if (!isEsmBundle(resolvedPath)) { + return err('workflow file must be ".ts" or end with ".esm.js"'); } let source: string; @@ -49,27 +122,54 @@ export async function cmdAdd( return validated; } + const yamlResolved = + args.descriptorPath !== null ? resolve(args.descriptorPath) : defaultDescriptorPath(resolvedPath); + + let yamlText: string; + try { + yamlText = await readFile(yamlResolved, "utf8"); + } catch { + return err(`descriptor YAML not found: ${yamlResolved}`); + } + + let dtsSource: { kind: "text"; text: string } | null = null; + if (args.typesPath !== null) { + const typesResolved = resolve(args.typesPath); + try { + const text = await readFile(typesResolved, "utf8"); + dtsSource = { kind: "text", text }; + } catch { + return err(`types file not found: ${typesResolved}`); + } + } else { + const typesDefault = defaultTypesPath(resolvedPath); + try { + const text = await readFile(typesDefault, "utf8"); + dtsSource = { kind: "text", text }; + } catch { + warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`); + } + } + const encoder = new TextEncoder(); const bytes = encoder.encode(source); const hash = hashWorkflowBundleBytes(bytes); - const stored = await storeWorkflowBundleCopy(storageRoot, hash, resolvedPath, source); + const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, { + esmJs: { kind: "text", text: source }, + yaml: { kind: "text", text: yamlText }, + dts: dtsSource, + }); if (!stored.ok) { return stored; } - const reg = await readWorkflowRegistry(storageRoot); - if (!reg.ok) { - return err(reg.error.message); + const regResult = await registerHash(storageRoot, args.name, hash); + if (!regResult.ok) { + return regResult; } - const next = registerWorkflowVersion(reg.value, name, hash, Date.now()); - const written = await writeWorkflowRegistry(storageRoot, next); - if (!written.ok) { - return err(written.error.message); - } - - return ok({ hash }); + return ok({ hash, warnings }); } export function formatAddSuccess(name: string, filePath: string, hash: string): string { diff --git a/packages/workflow/__tests__/build-pipeline.test.ts b/packages/workflow/__tests__/build-pipeline.test.ts new file mode 100644 index 0000000..f98001c --- /dev/null +++ b/packages/workflow/__tests__/build-pipeline.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { buildWorkflowFromTypeScript } from "../src/build-pipeline.js"; + +describe("buildWorkflowFromTypeScript", () => { + test("produces valid ESM bundle text, YAML, and d.ts from hello-world.ts", async () => { + const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url)); + const r = await buildWorkflowFromTypeScript(helloTs); + expect(r.ok).toBe(true); + if (!r.ok) { + return; + } + expect(r.value.esmJsSource.length).toBeGreaterThan(500); + expect(r.value.yamlSource).toContain("hello world"); + expect(r.value.dtsSource).toContain("greeter"); + expect(r.value.dtsSource).toContain("greeting: string"); + }); + + test("built bundle default export is executable", async () => { + const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url)); + const r = await buildWorkflowFromTypeScript(helloTs); + expect(r.ok).toBe(true); + if (!r.ok) { + return; + } + + const tmp = `/tmp/uncaged-wf-build-test-${Date.now()}.esm.js`; + await Bun.write(tmp, r.value.esmJsSource); + + const href = pathToFileURL(tmp).href; + const mod = (await import(href)) as { default: unknown }; + expect(typeof mod.default).toBe("function"); + }); +}); diff --git a/packages/workflow/__tests__/json-schema-to-ts.test.ts b/packages/workflow/__tests__/json-schema-to-ts.test.ts new file mode 100644 index 0000000..62b48eb --- /dev/null +++ b/packages/workflow/__tests__/json-schema-to-ts.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; + +import { jsonSchemaToTypeString } from "../src/json-schema-to-ts.js"; + +describe("jsonSchemaToTypeString", () => { + test("maps primitives and object required fields", () => { + const schema = { + type: "object", + properties: { + plan: { type: "string" }, + files: { type: "array", items: { type: "string" } }, + }, + required: ["plan", "files"], + }; + expect(jsonSchemaToTypeString(schema)).toBe("{ plan: string; files: string[] }"); + }); + + test("marks non-required object properties as nullable union", () => { + const schema = { + type: "object", + properties: { + n: { type: "number" }, + }, + required: [], + }; + expect(jsonSchemaToTypeString(schema)).toBe("{ n: number | null }"); + }); + + test("handles boolean and integer", () => { + expect(jsonSchemaToTypeString({ type: "boolean" })).toBe("boolean"); + expect(jsonSchemaToTypeString({ type: "integer" })).toBe("number"); + }); + + test("handles enum as literal union", () => { + expect(jsonSchemaToTypeString({ enum: ["a", "b"] })).toBe(`"a" | "b"`); + }); +}); diff --git a/packages/workflow/src/build-pipeline.ts b/packages/workflow/src/build-pipeline.ts new file mode 100644 index 0000000..c0f0af1 --- /dev/null +++ b/packages/workflow/src/build-pipeline.ts @@ -0,0 +1,122 @@ +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, + type WorkflowDescriptor, +} from "./workflow-descriptor.js"; + +export type BuildPipelineResult = { + esmJsSource: string; + yamlSource: string; + dtsSource: string; +}; + +async function findPackageRoot(startDir: string): Promise { + let dir = startDir; + for (;;) { + try { + await access(join(dir, "package.json"), constants.R_OK); + return dir; + } catch { + const parent = dirname(dir); + if (parent === dir) { + return startDir; + } + dir = parent; + } + } +} + +async function loadDescriptorFromSourceTs(absoluteTsPath: string): Promise< + Result +> { + let mod: Record; + try { + const href = pathToFileURL(absoluteTsPath).href; + // Dynamic import required: user workflow source path resolved at add/build time + mod = (await import(href)) as Record; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to import workflow source for descriptor: ${message}`); + } + + const raw = mod.descriptor; + return validateWorkflowDescriptor(raw); +} + +/** + * Bundle a `.ts` workflow entry with Bun, read `export const descriptor`, and emit + * companion YAML + `.d.ts` text alongside validated ESM bundle source. + */ +export async function buildWorkflowFromTypeScript( + absoluteTsPath: string, +): Promise> { + let rootDir: string; + try { + rootDir = await findPackageRoot(dirname(absoluteTsPath)); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to resolve package root: ${message}`); + } + + let buildResult: Awaited>; + try { + buildResult = await Bun.build({ + entrypoints: [absoluteTsPath], + target: "node", + format: "esm", + external: ["node:*"], + root: rootDir, + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`Bun.build failed: ${message}`); + } + + if (!buildResult.success) { + const logs = buildResult.logs.map((l) => l.message).join("; "); + return err(`Bun.build failed: ${logs || "unknown error"}`); + } + + const entry = buildResult.outputs.find((o) => o.kind === "entry-point"); + if (entry === undefined) { + return err("Bun.build produced no entry-point output"); + } + + let esmJsSource: string; + try { + esmJsSource = await entry.text(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to read bundle output: ${message}`); + } + + const descriptorLoaded = await loadDescriptorFromSourceTs(absoluteTsPath); + if (!descriptorLoaded.ok) { + return descriptorLoaded; + } + const descriptor = descriptorLoaded.value; + + const validated = validateWorkflowBundle({ + filePath: joinVirtualEsmPath(absoluteTsPath), + source: esmJsSource, + }); + if (!validated.ok) { + return validated; + } + + const yamlSource = stringifyWorkflowDescriptor(descriptor); + const dtsSource = generateWorkflowBundleTypes(descriptor); + + return ok({ esmJsSource, yamlSource, dtsSource }); +} + +function joinVirtualEsmPath(absoluteTsPath: string): string { + return `${absoluteTsPath}.esm.js`; +} diff --git a/packages/workflow/src/bundle-validator.ts b/packages/workflow/src/bundle-validator.ts index d3b7b76..47164ba 100644 --- a/packages/workflow/src/bundle-validator.ts +++ b/packages/workflow/src/bundle-validator.ts @@ -4,6 +4,8 @@ import type { ExportAllDeclaration, ExportDefaultDeclaration, ExportNamedDeclaration, + ExportSpecifier, + FunctionDeclaration, ImportDeclaration, Node, Program, @@ -66,11 +68,84 @@ function walkAst(node: Node, visit: (n: Node) => void): void { } } +function exportSpecifierIsDefaultReExport(spec: ExportSpecifier): boolean { + return spec.exported.type === "Identifier" && spec.exported.name === "default"; +} + function programHasDefaultExport(body: readonly Node[]): boolean { for (const stmt of body) { if (stmt.type === "ExportDefaultDeclaration") { return true; } + if (stmt.type === "ExportNamedDeclaration") { + 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)) { + return true; + } + } + } + } + return false; +} + +function findDefaultExportLocalBindingName(program: Program): string | null { + for (const stmt of program.body) { + if (stmt.type !== "ExportNamedDeclaration") { + continue; + } + const named = stmt as ExportNamedDeclaration; + if (named.source !== null && named.source !== undefined) { + continue; + } + for (const spec of named.specifiers) { + if (spec.type !== "ExportSpecifier" || !exportSpecifierIsDefaultReExport(spec)) { + continue; + } + const loc = spec.local; + if (loc.type !== "Identifier") { + return null; + } + return loc.name; + } + } + return null; +} + +function bindingInitializerIsCallable(init: Node): boolean { + return ( + init.type === "FunctionExpression" || + init.type === "ArrowFunctionExpression" || + init.type === "CallExpression" + ); +} + +function programDeclaresCallableExportBinding(program: Program, name: string): boolean { + for (const stmt of program.body) { + if (stmt.type === "FunctionDeclaration") { + const fd = stmt as FunctionDeclaration; + const id = fd.id; + if (id !== null && id !== undefined && id.type === "Identifier" && id.name === name) { + return true; + } + } + if (stmt.type === "VariableDeclaration") { + for (const decl of stmt.declarations) { + if (decl.id.type !== "Identifier" || decl.id.name !== name) { + continue; + } + const init = decl.init; + if (init === null || init === undefined) { + continue; + } + if (bindingInitializerIsCallable(init)) { + return true; + } + } + } } return false; } @@ -93,6 +168,11 @@ function defaultExportDeclarationIsCallable(program: Program): boolean { } return false; } + + const exportBinding = findDefaultExportLocalBindingName(program); + if (exportBinding !== null) { + return programDeclaresCallableExportBinding(program, exportBinding); + } return false; } diff --git a/packages/workflow/src/generate-descriptor.ts b/packages/workflow/src/generate-descriptor.ts new file mode 100644 index 0000000..1405ba7 --- /dev/null +++ b/packages/workflow/src/generate-descriptor.ts @@ -0,0 +1,8 @@ +import { stringify } from "yaml"; + +import type { WorkflowDescriptor } from "./workflow-descriptor.js"; + +/** Serialize a validated workflow descriptor to YAML for storage next to the bundle. */ +export function stringifyWorkflowDescriptor(descriptor: WorkflowDescriptor): string { + return stringify(descriptor, { indent: 2, defaultStringType: "QUOTE_DOUBLE" }); +} diff --git a/packages/workflow/src/generate-types.ts b/packages/workflow/src/generate-types.ts new file mode 100644 index 0000000..8c6653c --- /dev/null +++ b/packages/workflow/src/generate-types.ts @@ -0,0 +1,27 @@ +import { jsonSchemaToTypeString } from "./json-schema-to-ts.js"; +import type { WorkflowDescriptor } from "./workflow-descriptor.js"; + +function safePropertyName(name: string): string { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +/** Build the standard workflow bundle `.d.ts` from role JSON Schemas. */ +export function generateWorkflowBundleTypes(descriptor: WorkflowDescriptor): string { + const roleLines: string[] = []; + for (const [roleName, role] of Object.entries(descriptor.roles)) { + const tsType = jsonSchemaToTypeString(role.schema); + roleLines.push(` ${safePropertyName(roleName)}: ${tsType};`); + } + + return [ + `import type { WorkflowFn } from "@uncaged/workflow";`, + ``, + `export type Roles = {`, + ...roleLines, + `};`, + ``, + `declare const workflow: WorkflowFn;`, + `export default workflow;`, + ``, + ].join("\n"); +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index f2375a1..60af519 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -6,6 +6,13 @@ export { encodeUint64AsCrockford, } from "./base32.js"; export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js"; +export { + buildWorkflowFromTypeScript, + type BuildPipelineResult, +} from "./build-pipeline.js"; +export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; +export { generateWorkflowBundleTypes } from "./generate-types.js"; +export { jsonSchemaToTypeString } from "./json-schema-to-ts.js"; export { createRoleModerator } from "./create-role-moderator.js"; export { type ExecuteThreadIo, @@ -66,3 +73,9 @@ export { } from "./types.js"; export { generateUlid } from "./ulid.js"; export { getWorkerHostScriptPath } from "./worker-entry-path.js"; +export { + validateWorkflowDescriptor, + type WorkflowDescriptor, + type WorkflowRoleDescriptor, + type WorkflowRoleSchema, +} from "./workflow-descriptor.js"; diff --git a/packages/workflow/src/json-schema-to-ts.ts b/packages/workflow/src/json-schema-to-ts.ts new file mode 100644 index 0000000..9b7cc57 --- /dev/null +++ b/packages/workflow/src/json-schema-to-ts.ts @@ -0,0 +1,83 @@ +/** + * 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 schemaToTs(schema: unknown): string { + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + return "unknown"; + } + const s = schema as Record; + + if ("enum" in s && Array.isArray(s.enum) && s.enum.length > 0) { + const literals = s.enum + .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 "unknown"; + } + return literals.join(" | "); + } + + const t = s.type; + if (t === "string") { + return "string"; + } + if (t === "number" || t === "integer") { + return "number"; + } + if (t === "boolean") { + return "boolean"; + } + if (t === "array") { + const items = s.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)}[]`; + } + if (t === "object") { + const propsRaw = s.properties; + const requiredRaw = s.required; + const required = new Set( + Array.isArray(requiredRaw) + ? requiredRaw.filter((x): x is string => typeof x === "string") + : [], + ); + + if (propsRaw === null || propsRaw === undefined) { + return "Record"; + } + if (typeof propsRaw !== "object" || Array.isArray(propsRaw)) { + return "Record"; + } + + const props = propsRaw as Record; + const entries = Object.entries(props); + if (entries.length === 0) { + return "{}"; + } + + const parts: string[] = []; + for (const [key, subSchema] of entries) { + const optional = !required.has(key); + const ts = schemaToTs(subSchema); + const safeKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key); + const suffix = optional ? " | null" : ""; + parts.push(`${safeKey}: ${ts}${suffix}`); + } + return `{ ${parts.join("; ")} }`; + } + + return "unknown"; +} diff --git a/packages/workflow/src/workflow-descriptor.ts b/packages/workflow/src/workflow-descriptor.ts new file mode 100644 index 0000000..1324e6a --- /dev/null +++ b/packages/workflow/src/workflow-descriptor.ts @@ -0,0 +1,52 @@ +import { err, ok, type Result } from "./result.js"; + +/** JSON Schema fragment describing one role's `meta` shape (subset supported by code generation). */ +export type WorkflowRoleSchema = Record; + +export type WorkflowRoleDescriptor = { + description: string; + schema: WorkflowRoleSchema; +}; + +/** Workflow metadata exported as `export const descriptor` from TypeScript sources. */ +export type WorkflowDescriptor = { + description: string; + roles: Record; +}; + +export function validateWorkflowDescriptor(value: unknown): Result { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return err("descriptor must be a non-array object"); + } + const root = value as Record; + const description = root.description; + if (typeof description !== "string") { + return err("descriptor.description must be a string"); + } + const rolesRaw = root.roles; + if (rolesRaw === null || typeof rolesRaw !== "object" || Array.isArray(rolesRaw)) { + return err("descriptor.roles must be a non-array object"); + } + + const roles: Record = {}; + for (const [roleName, specUnknown] of Object.entries(rolesRaw)) { + if (specUnknown === null || typeof specUnknown !== "object" || Array.isArray(specUnknown)) { + return err(`descriptor.roles.${roleName} must be a non-array object`); + } + const spec = specUnknown as Record; + const roleDesc = spec.description; + if (typeof roleDesc !== "string") { + return err(`descriptor.roles.${roleName}.description must be a string`); + } + const schema = spec.schema; + if (schema === null || typeof schema !== "object" || Array.isArray(schema)) { + return err(`descriptor.roles.${roleName}.schema must be a non-array object`); + } + roles[roleName] = { + description: roleDesc, + schema: schema as WorkflowRoleSchema, + }; + } + + return ok({ description, roles }); +} diff --git a/packages/workflow/xxhashjs.d.ts b/packages/workflow/xxhashjs.d.ts deleted file mode 100644 index beee6d4..0000000 --- a/packages/workflow/xxhashjs.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -declare module "xxhashjs" { - type Digest = { - toString(radix?: number): string; - }; - - type Hasher64 = { - update(data: Buffer): Hasher64; - digest(): Digest; - }; - - type XXH = { - h64(seed: number): Hasher64; - }; - - const XXH: XXH; - export default XXH; -}