feat: build pipeline — .ts → .esm.js + .yaml + .d.ts 三件套
- add command auto-detects .ts vs .esm.js input - .ts: Bun.build → bundle + descriptor extraction + JSON Schema → .d.ts - .esm.js: requires .yaml alongside, .d.ts optional - JSON Schema → TypeScript type converter - hello-world example workflow - 63 tests pass, biome clean Closes #7 小橘 <xiaoju@shazhou.work>
This commit is contained in:
+10
@@ -38,6 +38,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["examples/**/*.ts", "packages/workflow/__tests__/fixtures/**/*.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linter": {
|
"linter": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const descriptor = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const greeter: Role<Roles["greeter"]> = async (ctx) => ({
|
const greeter: Role<Roles["greeter"]> = async (ctx) => ({
|
||||||
content: "Hello, " + ctx.start.content,
|
content: `Hello, ${ctx.start.content}`,
|
||||||
meta: { greeting: "Hello!" },
|
meta: { greeting: "Hello!" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { join } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
import { getRegisteredWorkflow, readWorkflowRegistry } from "@uncaged/workflow";
|
||||||
|
|
||||||
import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js";
|
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { cmdAdd } from "../src/cmd-add.js";
|
||||||
import { cmdHistory } from "../src/cmd-history.js";
|
import { cmdHistory } from "../src/cmd-history.js";
|
||||||
import { cmdList, formatListLines } from "../src/cmd-list.js";
|
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";
|
||||||
|
|
||||||
describe("cli workflow commands", () => {
|
describe("cli workflow commands", () => {
|
||||||
let prevEnv: string | undefined;
|
let prevEnv: string | undefined;
|
||||||
@@ -144,6 +143,39 @@ 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 () => {
|
||||||
|
const bundleDir = join(storageRoot, "w");
|
||||||
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
const bundlePath = join(bundleDir, "app.esm.js");
|
||||||
|
const yamlPath = join(bundleDir, "desc.yaml");
|
||||||
|
await writeFile(
|
||||||
|
bundlePath,
|
||||||
|
`export default async function* (input) {
|
||||||
|
yield { role: "a", content: "x", meta: {} };
|
||||||
|
return { returnCode: 0, summary: "x" };
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await writeFile(yamlPath, MINIMAL_DESCRIPTOR_YAML, "utf8");
|
||||||
|
|
||||||
|
const added = await cmdAdd(storageRoot, {
|
||||||
|
name: "app",
|
||||||
|
filePath: bundlePath,
|
||||||
|
descriptorPath: yamlPath,
|
||||||
|
typesPath: null,
|
||||||
|
});
|
||||||
|
expect(added.ok).toBe(true);
|
||||||
|
if (!added.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const yamlStored = await readFile(
|
||||||
|
join(storageRoot, "bundles", `${added.value.hash}.yaml`),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(yamlStored).toContain("fixture");
|
||||||
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const bundleDir = join(storageRoot, "src");
|
const bundleDir = join(storageRoot, "src");
|
||||||
await mkdir(bundleDir, { recursive: true });
|
await mkdir(bundleDir, { recursive: true });
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|||||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readFile, rm, 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 { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js";
|
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
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";
|
||||||
|
|
||||||
/** 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 default async function* (input) {
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js";
|
|
||||||
import { cmdAdd } from "../src/cmd-add.js";
|
import { cmdAdd } from "../src/cmd-add.js";
|
||||||
import { cmdKill } from "../src/cmd-kill.js";
|
import { cmdKill } from "../src/cmd-kill.js";
|
||||||
import { cmdPause } from "../src/cmd-pause.js";
|
import { cmdPause } from "../src/cmd-pause.js";
|
||||||
@@ -15,6 +13,7 @@ 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";
|
||||||
|
|
||||||
const fastBundleSource = `export default async function* (input) {
|
const fastBundleSource = `export default async function* (input) {
|
||||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||||
|
|||||||
@@ -9,49 +9,81 @@ export type ParsedAddArgv = {
|
|||||||
typesPath: string | null;
|
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> {
|
export function parseAddArgv(argv: string[]): Result<ParsedAddArgv, string> {
|
||||||
let name: string | undefined;
|
const slots: PositionalSlots = { name: undefined, filePath: undefined };
|
||||||
let filePath: string | undefined;
|
|
||||||
let descriptorPath: string | null = null;
|
let descriptorPath: string | null = null;
|
||||||
let typesPath: string | null = null;
|
let typesPath: string | null = null;
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < argv.length) {
|
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];
|
const tok = argv[i];
|
||||||
if (tok === "--descriptor") {
|
if (tok?.startsWith("--")) {
|
||||||
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}`);
|
return err(`unknown add flag: ${tok}`);
|
||||||
}
|
}
|
||||||
if (tok === undefined) {
|
if (tok === undefined) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (name === undefined) {
|
const placed = assignPositional(tok, slots);
|
||||||
name = tok;
|
if (!placed.ok) {
|
||||||
} else if (filePath === undefined) {
|
return placed;
|
||||||
filePath = tok;
|
|
||||||
} else {
|
|
||||||
return err("too many arguments");
|
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { name, filePath } = slots;
|
||||||
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
|
if (name === undefined || name === "" || filePath === undefined || filePath === "") {
|
||||||
return err("add requires <name> <file>");
|
return err("add requires <name> <file>");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ async function pathExists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BundleFileSource =
|
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
||||||
| { kind: "text"; text: string }
|
|
||||||
| { kind: "path"; path: string };
|
|
||||||
|
|
||||||
export type WorkflowBundleStoreInput = {
|
export type WorkflowBundleStoreInput = {
|
||||||
esmJs: BundleFileSource;
|
esmJs: BundleFileSource;
|
||||||
|
|||||||
@@ -56,6 +56,127 @@ async function registerHash(
|
|||||||
return ok(undefined);
|
return ok(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addFromTypeScript(
|
||||||
|
storageRoot: string,
|
||||||
|
workflowName: string,
|
||||||
|
resolvedTsPath: string,
|
||||||
|
): Promise<Result<CmdAddSuccess, string>> {
|
||||||
|
const built = await buildWorkflowFromTypeScript(resolvedTsPath);
|
||||||
|
if (!built.ok) {
|
||||||
|
return built;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(built.value.esmJsSource);
|
||||||
|
const hash = hashWorkflowBundleBytes(bytes);
|
||||||
|
|
||||||
|
const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, {
|
||||||
|
esmJs: { kind: "text", text: built.value.esmJsSource },
|
||||||
|
yaml: { kind: "text", text: built.value.yamlSource },
|
||||||
|
dts: { kind: "text", text: built.value.dtsSource },
|
||||||
|
});
|
||||||
|
if (!stored.ok) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regResult = await registerHash(storageRoot, workflowName, hash);
|
||||||
|
if (!regResult.ok) {
|
||||||
|
return regResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ hash, warnings: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveYamlAndOptionalTypes(
|
||||||
|
args: ParsedAddArgv,
|
||||||
|
resolvedBundlePath: string,
|
||||||
|
): Promise<Result<{ yamlText: string; dtsText: string | null; warnings: string[] }, string>> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const yamlResolved =
|
||||||
|
args.descriptorPath !== null
|
||||||
|
? resolve(args.descriptorPath)
|
||||||
|
: defaultDescriptorPath(resolvedBundlePath);
|
||||||
|
|
||||||
|
let yamlText: string;
|
||||||
|
try {
|
||||||
|
yamlText = await readFile(yamlResolved, "utf8");
|
||||||
|
} catch {
|
||||||
|
return err(`descriptor YAML not found: ${yamlResolved}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dtsText: string | null = null;
|
||||||
|
if (args.typesPath !== null) {
|
||||||
|
const typesResolved = resolve(args.typesPath);
|
||||||
|
try {
|
||||||
|
dtsText = await readFile(typesResolved, "utf8");
|
||||||
|
} catch {
|
||||||
|
return err(`types file not found: ${typesResolved}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const typesDefault = defaultTypesPath(resolvedBundlePath);
|
||||||
|
try {
|
||||||
|
dtsText = await readFile(typesDefault, "utf8");
|
||||||
|
} catch {
|
||||||
|
warnings.push(`optional types file not found (${basename(typesDefault)}); skipped`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ yamlText, dtsText, warnings });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFromEsmJs(
|
||||||
|
storageRoot: string,
|
||||||
|
workflowName: string,
|
||||||
|
args: ParsedAddArgv,
|
||||||
|
resolvedBundlePath: string,
|
||||||
|
): Promise<Result<CmdAddSuccess, string>> {
|
||||||
|
let source: string;
|
||||||
|
try {
|
||||||
|
source = await readFile(resolvedBundlePath, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
|
return err(`failed to read bundle: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = validateWorkflowBundle({
|
||||||
|
filePath: resolvedBundlePath,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
if (!validated.ok) {
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companions = await resolveYamlAndOptionalTypes(args, resolvedBundlePath);
|
||||||
|
if (!companions.ok) {
|
||||||
|
return companions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(source);
|
||||||
|
const hash = hashWorkflowBundleBytes(bytes);
|
||||||
|
|
||||||
|
const dts =
|
||||||
|
companions.value.dtsText === null
|
||||||
|
? null
|
||||||
|
: { kind: "text" as const, text: companions.value.dtsText };
|
||||||
|
|
||||||
|
const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, {
|
||||||
|
esmJs: { kind: "text", text: source },
|
||||||
|
yaml: { kind: "text", text: companions.value.yamlText },
|
||||||
|
dts,
|
||||||
|
});
|
||||||
|
if (!stored.ok) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regResult = await registerHash(storageRoot, workflowName, hash);
|
||||||
|
if (!regResult.ok) {
|
||||||
|
return regResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ hash, warnings: companions.value.warnings });
|
||||||
|
}
|
||||||
|
|
||||||
export async function cmdAdd(
|
export async function cmdAdd(
|
||||||
storageRoot: string,
|
storageRoot: string,
|
||||||
args: ParsedAddArgv,
|
args: ParsedAddArgv,
|
||||||
@@ -73,103 +194,15 @@ export async function cmdAdd(
|
|||||||
return err(`file not found: ${args.filePath}`);
|
return err(`file not found: ${args.filePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
if (isTypeScriptWorkflow(resolvedPath)) {
|
if (isTypeScriptWorkflow(resolvedPath)) {
|
||||||
const built = await buildWorkflowFromTypeScript(resolvedPath);
|
return addFromTypeScript(storageRoot, args.name, 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)) {
|
if (!isEsmBundle(resolvedPath)) {
|
||||||
return err('workflow file must be ".ts" or end with ".esm.js"');
|
return err('workflow file must be ".ts" or end with ".esm.js"');
|
||||||
}
|
}
|
||||||
|
|
||||||
let source: string;
|
return addFromEsmJs(storageRoot, args.name, args, resolvedPath);
|
||||||
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 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 storeWorkflowBundleArtifacts(storageRoot, hash, {
|
|
||||||
esmJs: { kind: "text", text: source },
|
|
||||||
yaml: { kind: "text", text: yamlText },
|
|
||||||
dts: dtsSource,
|
|
||||||
});
|
|
||||||
if (!stored.ok) {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
|
|
||||||
const regResult = await registerHash(storageRoot, args.name, hash);
|
|
||||||
if (!regResult.ok) {
|
|
||||||
return regResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ok({ hash, warnings });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAddSuccess(name: string, filePath: string, hash: string): string {
|
export function formatAddSuccess(name: string, filePath: string, hash: string): string {
|
||||||
|
|||||||
@@ -1,35 +1,34 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
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 { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
import { buildWorkflowFromTypeScript } from "../src/build-pipeline.js";
|
import { buildWorkflowFromTypeScript } from "../src/build-pipeline.js";
|
||||||
|
|
||||||
describe("buildWorkflowFromTypeScript", () => {
|
describe("buildWorkflowFromTypeScript", () => {
|
||||||
test("produces valid ESM bundle text, YAML, and d.ts from hello-world.ts", async () => {
|
test("produces ESM + YAML + d.ts and the bundle default export runs", async () => {
|
||||||
const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url));
|
const thisFile = fileURLToPath(import.meta.url);
|
||||||
const r = await buildWorkflowFromTypeScript(helloTs);
|
const entryTs = join(dirname(thisFile), "fixtures/minimal-build-workflow.ts");
|
||||||
|
|
||||||
|
const r = await buildWorkflowFromTypeScript(entryTs);
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
expect(r.value.esmJsSource.length).toBeGreaterThan(500);
|
expect(r.value.esmJsSource.length).toBeGreaterThan(200);
|
||||||
expect(r.value.yamlSource).toContain("hello world");
|
expect(r.value.yamlSource).toContain("minimal fixture");
|
||||||
expect(r.value.dtsSource).toContain("greeter");
|
expect(r.value.dtsSource).toContain("r:");
|
||||||
expect(r.value.dtsSource).toContain("greeting: string");
|
expect(r.value.dtsSource).toContain("x: string");
|
||||||
});
|
|
||||||
|
|
||||||
test("built bundle default export is executable", async () => {
|
const dir = await mkdtemp(join(tmpdir(), "uncaged-wf-build-"));
|
||||||
const helloTs = fileURLToPath(new URL("../../../examples/hello-world.ts", import.meta.url));
|
try {
|
||||||
const r = await buildWorkflowFromTypeScript(helloTs);
|
const out = join(dir, "workflow.esm.js");
|
||||||
expect(r.ok).toBe(true);
|
await writeFile(out, r.value.esmJsSource, "utf8");
|
||||||
if (!r.ok) {
|
const mod = (await import(pathToFileURL(out).href)) as { default: unknown };
|
||||||
return;
|
expect(typeof mod.default).toBe("function");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,14 @@ import { describe, expect, test } from "bun:test";
|
|||||||
import { validateWorkflowBundle } from "../src/bundle-validator.js";
|
import { validateWorkflowBundle } from "../src/bundle-validator.js";
|
||||||
|
|
||||||
describe("validateWorkflowBundle", () => {
|
describe("validateWorkflowBundle", () => {
|
||||||
|
test("accepts export { local as default } when local is a call expression result", () => {
|
||||||
|
const source = `var wf = createFn({});
|
||||||
|
export { wf as default };
|
||||||
|
`;
|
||||||
|
const r = validateWorkflowBundle({ filePath: "/tmp/w.esm.js", source });
|
||||||
|
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 = `import fs from "node:fs";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,10 +6,7 @@ import { validateWorkflowBundle } from "./bundle-validator.js";
|
|||||||
import { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
import { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
||||||
import { generateWorkflowBundleTypes } from "./generate-types.js";
|
import { generateWorkflowBundleTypes } from "./generate-types.js";
|
||||||
import { err, ok, type Result } from "./result.js";
|
import { err, ok, type Result } from "./result.js";
|
||||||
import {
|
import { validateWorkflowDescriptor } from "./workflow-descriptor.js";
|
||||||
validateWorkflowDescriptor,
|
|
||||||
type WorkflowDescriptor,
|
|
||||||
} from "./workflow-descriptor.js";
|
|
||||||
|
|
||||||
export type BuildPipelineResult = {
|
export type BuildPipelineResult = {
|
||||||
esmJsSource: string;
|
esmJsSource: string;
|
||||||
@@ -33,9 +30,9 @@ async function findPackageRoot(startDir: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDescriptorFromSourceTs(absoluteTsPath: string): Promise<
|
async function loadDescriptorFromSourceTs(
|
||||||
Result<WorkflowDescriptor, string>
|
absoluteTsPath: string,
|
||||||
> {
|
): Promise<Result<WorkflowDescriptor, string>> {
|
||||||
let mod: Record<string, unknown>;
|
let mod: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
const href = pathToFileURL(absoluteTsPath).href;
|
const href = pathToFileURL(absoluteTsPath).href;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
ImportDeclaration,
|
ImportDeclaration,
|
||||||
Node,
|
Node,
|
||||||
Program,
|
Program,
|
||||||
|
VariableDeclaration,
|
||||||
} from "acorn";
|
} from "acorn";
|
||||||
import * as acorn from "acorn";
|
import * as acorn from "acorn";
|
||||||
|
|
||||||
@@ -72,21 +73,22 @@ function exportSpecifierIsDefaultReExport(spec: ExportSpecifier): boolean {
|
|||||||
return spec.exported.type === "Identifier" && spec.exported.name === "default";
|
return spec.exported.type === "Identifier" && spec.exported.name === "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportNamedDeclarationOffersDefault(named: ExportNamedDeclaration): boolean {
|
||||||
|
if (named.source !== null && named.source !== undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return named.specifiers.some(
|
||||||
|
(spec) => spec.type === "ExportSpecifier" && exportSpecifierIsDefaultReExport(spec),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function programHasDefaultExport(body: readonly Node[]): boolean {
|
function programHasDefaultExport(body: readonly Node[]): boolean {
|
||||||
for (const stmt of body) {
|
for (const stmt of body) {
|
||||||
if (stmt.type === "ExportDefaultDeclaration") {
|
if (stmt.type === "ExportDefaultDeclaration") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (stmt.type === "ExportNamedDeclaration") {
|
if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclarationOffersDefault(stmt)) {
|
||||||
const named = stmt as ExportNamedDeclaration;
|
return true;
|
||||||
if (named.source !== null && named.source !== undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const spec of named.specifiers) {
|
|
||||||
if (spec.type === "ExportSpecifier" && exportSpecifierIsDefaultReExport(spec)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -123,6 +125,22 @@ function bindingInitializerIsCallable(init: Node): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function variableDeclarationBindsCallableName(stmt: VariableDeclaration, name: string): boolean {
|
||||||
|
for (const decl of stmt.declarations) {
|
||||||
|
if (decl.id.type !== "Identifier" || decl.id.name !== name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const init = decl.init;
|
||||||
|
if (init === null || init === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bindingInitializerIsCallable(init)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function programDeclaresCallableExportBinding(program: Program, name: string): boolean {
|
function programDeclaresCallableExportBinding(program: Program, name: string): boolean {
|
||||||
for (const stmt of program.body) {
|
for (const stmt of program.body) {
|
||||||
if (stmt.type === "FunctionDeclaration") {
|
if (stmt.type === "FunctionDeclaration") {
|
||||||
@@ -132,19 +150,8 @@ function programDeclaresCallableExportBinding(program: Program, name: string): b
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stmt.type === "VariableDeclaration") {
|
if (stmt.type === "VariableDeclaration" && variableDeclarationBindsCallableName(stmt, name)) {
|
||||||
for (const decl of stmt.declarations) {
|
return true;
|
||||||
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;
|
return false;
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ export {
|
|||||||
encodeCrockfordBase32Bits,
|
encodeCrockfordBase32Bits,
|
||||||
encodeUint64AsCrockford,
|
encodeUint64AsCrockford,
|
||||||
} from "./base32.js";
|
} from "./base32.js";
|
||||||
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
|
||||||
export {
|
export {
|
||||||
buildWorkflowFromTypeScript,
|
|
||||||
type BuildPipelineResult,
|
type BuildPipelineResult,
|
||||||
|
buildWorkflowFromTypeScript,
|
||||||
} from "./build-pipeline.js";
|
} from "./build-pipeline.js";
|
||||||
export { stringifyWorkflowDescriptor } from "./generate-descriptor.js";
|
export { validateWorkflowBundle, type WorkflowBundleValidationInput } from "./bundle-validator.js";
|
||||||
export { generateWorkflowBundleTypes } from "./generate-types.js";
|
|
||||||
export { jsonSchemaToTypeString } from "./json-schema-to-ts.js";
|
|
||||||
export { createRoleModerator } from "./create-role-moderator.js";
|
export { createRoleModerator } from "./create-role-moderator.js";
|
||||||
export {
|
export {
|
||||||
type ExecuteThreadIo,
|
type ExecuteThreadIo,
|
||||||
@@ -28,7 +25,10 @@ export {
|
|||||||
parseThreadDataJsonl,
|
parseThreadDataJsonl,
|
||||||
selectForkHistoricalSteps,
|
selectForkHistoricalSteps,
|
||||||
} from "./fork-thread.js";
|
} from "./fork-thread.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,
|
||||||
|
|||||||
@@ -6,23 +6,84 @@ export function jsonSchemaToTypeString(schema: unknown): string {
|
|||||||
return schemaToTs(schema);
|
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 {
|
function schemaToTs(schema: unknown): string {
|
||||||
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
const s = schema as Record<string, unknown>;
|
const record = schema as Record<string, unknown>;
|
||||||
|
|
||||||
if ("enum" in s && Array.isArray(s.enum) && s.enum.length > 0) {
|
const fromEnum = schemaEnumToTs(record);
|
||||||
const literals = s.enum
|
if (fromEnum !== null) {
|
||||||
.filter((v): v is string | number | boolean => v !== null && v !== undefined)
|
return fromEnum;
|
||||||
.map((v) => (typeof v === "string" ? JSON.stringify(v) : String(v)));
|
|
||||||
if (literals.length === 0) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
return literals.join(" | ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = s.type;
|
const t = record.type;
|
||||||
if (t === "string") {
|
if (t === "string") {
|
||||||
return "string";
|
return "string";
|
||||||
}
|
}
|
||||||
@@ -32,51 +93,15 @@ function schemaToTs(schema: unknown): string {
|
|||||||
if (t === "boolean") {
|
if (t === "boolean") {
|
||||||
return "boolean";
|
return "boolean";
|
||||||
}
|
}
|
||||||
if (t === "array") {
|
|
||||||
const items = s.items;
|
const fromArray = schemaArrayToTs(record);
|
||||||
if (items === undefined || items === null) {
|
if (fromArray !== null) {
|
||||||
return "unknown[]";
|
return fromArray;
|
||||||
}
|
|
||||||
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<string>(
|
|
||||||
Array.isArray(requiredRaw)
|
|
||||||
? requiredRaw.filter((x): x is string => typeof x === "string")
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (propsRaw === null || propsRaw === undefined) {
|
const fromObject = schemaObjectToTs(record);
|
||||||
return "Record<string, unknown>";
|
if (fromObject !== null) {
|
||||||
}
|
return fromObject;
|
||||||
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("; ")} }`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "unknown";
|
return "unknown";
|
||||||
|
|||||||
Reference in New Issue
Block a user