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:
@@ -5,14 +5,13 @@ 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";
|
||||
import { cmdRemove } from "../src/cmd-remove.js";
|
||||
import { cmdRollback } from "../src/cmd-rollback.js";
|
||||
import { cmdShow } from "../src/cmd-show.js";
|
||||
import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js";
|
||||
|
||||
describe("cli workflow commands", () => {
|
||||
let prevEnv: string | undefined;
|
||||
@@ -144,6 +143,39 @@ export default async function* (input) {
|
||||
expect(entry.hash).toBe(hash);
|
||||
});
|
||||
|
||||
test("add from .esm.js with --descriptor uses explicit YAML path", async () => {
|
||||
const bundleDir = join(storageRoot, "w");
|
||||
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 () => {
|
||||
const bundleDir = join(storageRoot, "src");
|
||||
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 { 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";
|
||||
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. */
|
||||
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 { 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";
|
||||
@@ -15,6 +13,7 @@ import { cmdRun } from "../src/cmd-run.js";
|
||||
import { cmdThreadRemove, cmdThreadShow } from "../src/cmd-thread.js";
|
||||
import { cmdThreads } from "../src/cmd-threads.js";
|
||||
import { pathExists } from "../src/fs-utils.js";
|
||||
import { addCliArgs, MINIMAL_DESCRIPTOR_YAML } from "./bundle-fixture.js";
|
||||
|
||||
const fastBundleSource = `export default async function* (input) {
|
||||
yield { role: "planner", content: "plan", meta: { plan: input.prompt } };
|
||||
|
||||
@@ -9,49 +9,81 @@ export type ParsedAddArgv = {
|
||||
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> {
|
||||
let name: string | undefined;
|
||||
let filePath: string | undefined;
|
||||
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 === "--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("--")) {
|
||||
if (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");
|
||||
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>");
|
||||
}
|
||||
|
||||
@@ -12,9 +12,7 @@ async function pathExists(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export type BundleFileSource =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "path"; path: string };
|
||||
export type BundleFileSource = { kind: "text"; text: string } | { kind: "path"; path: string };
|
||||
|
||||
export type WorkflowBundleStoreInput = {
|
||||
esmJs: BundleFileSource;
|
||||
|
||||
@@ -56,6 +56,127 @@ async function registerHash(
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
async function addFromTypeScript(
|
||||
storageRoot: string,
|
||||
workflowName: string,
|
||||
resolvedTsPath: string,
|
||||
): Promise<Result<CmdAddSuccess, string>> {
|
||||
const built = await buildWorkflowFromTypeScript(resolvedTsPath);
|
||||
if (!built.ok) {
|
||||
return built;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(built.value.esmJsSource);
|
||||
const hash = hashWorkflowBundleBytes(bytes);
|
||||
|
||||
const stored = await storeWorkflowBundleArtifacts(storageRoot, hash, {
|
||||
esmJs: { kind: "text", text: built.value.esmJsSource },
|
||||
yaml: { kind: "text", text: built.value.yamlSource },
|
||||
dts: { kind: "text", text: built.value.dtsSource },
|
||||
});
|
||||
if (!stored.ok) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const regResult = await registerHash(storageRoot, workflowName, hash);
|
||||
if (!regResult.ok) {
|
||||
return regResult;
|
||||
}
|
||||
|
||||
return ok({ hash, warnings: [] });
|
||||
}
|
||||
|
||||
async function resolveYamlAndOptionalTypes(
|
||||
args: ParsedAddArgv,
|
||||
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(
|
||||
storageRoot: string,
|
||||
args: ParsedAddArgv,
|
||||
@@ -73,103 +194,15 @@ export async function cmdAdd(
|
||||
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 });
|
||||
return addFromTypeScript(storageRoot, args.name, resolvedPath);
|
||||
}
|
||||
|
||||
if (!isEsmBundle(resolvedPath)) {
|
||||
return err('workflow file must be ".ts" or end with ".esm.js"');
|
||||
}
|
||||
|
||||
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 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 });
|
||||
return addFromEsmJs(storageRoot, args.name, args, resolvedPath);
|
||||
}
|
||||
|
||||
export function formatAddSuccess(name: string, filePath: string, hash: string): string {
|
||||
|
||||
Reference in New Issue
Block a user