From b07f8cf166702a43dbbc5025503277bb3e050eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Sat, 9 May 2026 11:16:27 +0800 Subject: [PATCH] feat(register): create @uncaged/workflow-register package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges bundle/ + registry/ + config/ modules. The config↔registry circular dependency is resolved: ProviderConfig and WorkflowConfig now come from @uncaged/workflow-protocol. Ref: #143, closes #149 --- packages/workflow-register/package.json | 26 ++ .../src/bundle/build-descriptor.ts | 24 + .../src/bundle/bundle-import-env.ts | 8 + .../src/bundle/bundle-validator.ts | 422 ++++++++++++++++++ .../bundle/ensure-uncaged-workflow-symlink.ts | 36 ++ .../src/bundle/extract-bundle-exports.ts | 42 ++ .../src/bundle/generate-descriptor.ts | 8 + .../workflow-register/src/bundle/index.ts | 15 + .../workflow-register/src/bundle/types.ts | 25 ++ .../src/bundle/workflow-descriptor.ts | 40 ++ .../workflow-register/src/config/index.ts | 3 + .../src/config/resolve-model.ts | 30 ++ .../src/config/split-provider-model-ref.ts | 17 + .../workflow-register/src/config/types.ts | 1 + packages/workflow-register/src/index.ts | 39 ++ .../workflow-register/src/registry/index.ts | 18 + .../src/registry/registry-normalize.ts | 225 ++++++++++ .../src/registry/registry.ts | 144 ++++++ .../workflow-register/src/registry/types.ts | 19 + packages/workflow-register/tsconfig.json | 25 ++ 20 files changed, 1167 insertions(+) create mode 100644 packages/workflow-register/package.json create mode 100644 packages/workflow-register/src/bundle/build-descriptor.ts create mode 100644 packages/workflow-register/src/bundle/bundle-import-env.ts create mode 100644 packages/workflow-register/src/bundle/bundle-validator.ts create mode 100644 packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts create mode 100644 packages/workflow-register/src/bundle/extract-bundle-exports.ts create mode 100644 packages/workflow-register/src/bundle/generate-descriptor.ts create mode 100644 packages/workflow-register/src/bundle/index.ts create mode 100644 packages/workflow-register/src/bundle/types.ts create mode 100644 packages/workflow-register/src/bundle/workflow-descriptor.ts create mode 100644 packages/workflow-register/src/config/index.ts create mode 100644 packages/workflow-register/src/config/resolve-model.ts create mode 100644 packages/workflow-register/src/config/split-provider-model-ref.ts create mode 100644 packages/workflow-register/src/config/types.ts create mode 100644 packages/workflow-register/src/index.ts create mode 100644 packages/workflow-register/src/registry/index.ts create mode 100644 packages/workflow-register/src/registry/registry-normalize.ts create mode 100644 packages/workflow-register/src/registry/registry.ts create mode 100644 packages/workflow-register/src/registry/types.ts create mode 100644 packages/workflow-register/tsconfig.json diff --git a/packages/workflow-register/package.json b/packages/workflow-register/package.json new file mode 100644 index 0000000..6bb67fd --- /dev/null +++ b/packages/workflow-register/package.json @@ -0,0 +1,26 @@ +{ + "name": "@uncaged/workflow-register", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@uncaged/workflow-protocol": "workspace:*", + "@uncaged/workflow-util": "workspace:*" + }, + "peerDependencies": { + "acorn": "^8.0.0", + "yaml": "^2.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "acorn": "^8.14.1", + "yaml": "^2.7.1", + "zod": "^4.0.0", + "typescript": "^5.8.3" + } +} diff --git a/packages/workflow-register/src/bundle/build-descriptor.ts b/packages/workflow-register/src/bundle/build-descriptor.ts new file mode 100644 index 0000000..2bf4a7b --- /dev/null +++ b/packages/workflow-register/src/bundle/build-descriptor.ts @@ -0,0 +1,24 @@ +import type { RoleMeta, WorkflowDefinition } from "@uncaged/workflow-protocol"; +import * as z from "zod/v4"; +import type { WorkflowDescriptor, WorkflowRoleSchema } from "./types.js"; + +function stripJsonSchemaMeta(json: Record): WorkflowRoleSchema { + const { $schema: _drop, ...rest } = json; + return rest as WorkflowRoleSchema; +} + +export function buildDescriptor( + def: WorkflowDefinition, +): WorkflowDescriptor { + const roles: WorkflowDescriptor["roles"] = {}; + for (const [key, roleDef] of Object.entries(def.roles) as Array< + [string, { description: string; schema: z.ZodType }] + >) { + const rawJsonSchema = z.toJSONSchema(roleDef.schema) as Record; + roles[key] = { + description: roleDef.description, + schema: stripJsonSchemaMeta(rawJsonSchema), + }; + } + return { description: def.description, roles }; +} diff --git a/packages/workflow-register/src/bundle/bundle-import-env.ts b/packages/workflow-register/src/bundle/bundle-import-env.ts new file mode 100644 index 0000000..ef1bf1c --- /dev/null +++ b/packages/workflow-register/src/bundle/bundle-import-env.ts @@ -0,0 +1,8 @@ +import { pathToFileURL } from "node:url"; + +/** + * Dynamic-import a workflow bundle path (see {@link extractBundleExports} — symlink must exist first). + */ +export async function importWorkflowBundleModule(bundlePath: string): Promise { + return import(pathToFileURL(bundlePath).href); +} diff --git a/packages/workflow-register/src/bundle/bundle-validator.ts b/packages/workflow-register/src/bundle/bundle-validator.ts new file mode 100644 index 0000000..1ebfbd8 --- /dev/null +++ b/packages/workflow-register/src/bundle/bundle-validator.ts @@ -0,0 +1,422 @@ +import { isBuiltin } from "node:module"; +import type { + CallExpression, + ExportAllDeclaration, + ExportNamedDeclaration, + ExportSpecifier, + FunctionDeclaration, + ImportDeclaration, + Node, + Program, + VariableDeclaration, +} from "acorn"; +import * as acorn from "acorn"; + +import { err, ok, type Result } from "@uncaged/workflow-util"; + +import type { WorkflowBundleValidationInput } from "./types.js"; + +/** Acorn Node with index-access for property traversal. */ +type AcornNode = Node & { [key: string]: unknown }; + +/** + * Narrow an Acorn Node to a specific AST subtype after a `.type` guard. + * Avoids double-cast (`as unknown as T`) by going through AcornNode. + */ +function narrowNode(node: Node): T { + return node as unknown as T; +} + +function endsWithEsmJs(path: string): boolean { + return path.endsWith(".esm.js"); +} + +function isAllowedImportSpecifier(spec: string): boolean { + if (spec.length === 0) { + return false; + } + if (spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("file:")) { + return false; + } + if (spec === "@uncaged/workflow" || spec === "@uncaged/workflow-runtime") { + return true; + } + return isBuiltin(spec); +} + +function pushNestedAstNodes(value: unknown, out: Node[]): void { + if (value === null || value === undefined) { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + if (item !== null && typeof item === "object" && "type" in item) { + out.push(item as Node); + } + } + return; + } + if (typeof value === "object" && "type" in value) { + out.push(value as Node); + } +} + +function collectChildNodes(node: Node): Node[] { + const children: Node[] = []; + for (const key of Object.keys(node)) { + const val = (node as AcornNode)[key]; + pushNestedAstNodes(val, children); + } + return children; +} + +function walkAst(node: Node, visit: (n: Node) => void): void { + visit(node); + for (const child of collectChildNodes(node)) { + walkAst(child, visit); + } +} + +function exportSpecifierExportedName(spec: ExportSpecifier): string | null { + if (spec.exported.type !== "Identifier") { + return null; + } + return spec.exported.name; +} + +function exportNamedDeclReExportsDefault(named: ExportNamedDeclaration): boolean { + if (named.source !== null && named.source !== undefined) { + return false; + } + return named.specifiers.some( + (spec) => spec.type === "ExportSpecifier" && exportSpecifierExportedName(spec) === "default", + ); +} + +function programUsesDefaultExport(program: Program): boolean { + for (const stmt of program.body) { + if (stmt.type === "ExportDefaultDeclaration") { + return true; + } + if (stmt.type === "ExportNamedDeclaration" && exportNamedDeclReExportsDefault(stmt)) { + return true; + } + } + return false; +} + +function bindingInitializerIsCallable(init: Node): boolean { + return ( + init.type === "FunctionExpression" || + init.type === "ArrowFunctionExpression" || + init.type === "CallExpression" + ); +} + +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 { + 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" && variableDeclarationBindsCallableName(stmt, name)) { + return true; + } + } + return false; +} + +function namedExportDeclExportsRunCallable(named: ExportNamedDeclaration): boolean { + const decl = named.declaration; + if (decl === null || decl === undefined) { + return false; + } + if (decl.type === "FunctionDeclaration") { + const id = decl.id; + return id !== null && id !== undefined && id.type === "Identifier" && id.name === "run"; + } + if (decl.type === "VariableDeclaration") { + return variableDeclarationBindsCallableName(decl, "run"); + } + return false; +} + +function findRunExportLocalBindingName(program: Program): string | null { + for (const stmt of program.body) { + if (stmt.type !== "ExportNamedDeclaration") { + continue; + } + const named = stmt as ExportNamedDeclaration; + if (named.source !== null && named.source !== undefined) { + continue; + } + for (const spec of named.specifiers) { + if (spec.type !== "ExportSpecifier" || exportSpecifierExportedName(spec) !== "run") { + continue; + } + const loc = spec.local; + if (loc.type !== "Identifier") { + return null; + } + return loc.name; + } + } + return null; +} + +function runExportIsCallable(program: Program): boolean { + for (const stmt of program.body) { + if (stmt.type === "ExportNamedDeclaration") { + const named = stmt as ExportNamedDeclaration; + if (namedExportDeclExportsRunCallable(named)) { + return true; + } + } + } + + const exportBinding = findRunExportLocalBindingName(program); + if (exportBinding !== null) { + return programDeclaresCallableExportBinding(program, exportBinding); + } + return false; +} + +function namedExportDeclExportsDescriptor(named: ExportNamedDeclaration): boolean { + const decl = named.declaration; + if (decl === null || decl === undefined || decl.type !== "VariableDeclaration") { + return false; + } + for (const d of decl.declarations) { + if (d.id.type === "Identifier" && d.id.name === "descriptor") { + return true; + } + } + return false; +} + +function functionDeclarationNamed(stmt: FunctionDeclaration, name: string): boolean { + const id = stmt.id; + return id !== null && id !== undefined && id.type === "Identifier" && id.name === name; +} + +function variableDeclarationNames(stmt: VariableDeclaration, name: string): boolean { + for (const decl of stmt.declarations) { + if (decl.id.type === "Identifier" && decl.id.name === name) { + return true; + } + } + return false; +} + +function programDeclaresBindingName(program: Program, name: string): boolean { + for (const stmt of program.body) { + if ( + stmt.type === "FunctionDeclaration" && + functionDeclarationNamed(stmt as FunctionDeclaration, name) + ) { + return true; + } + if (stmt.type === "VariableDeclaration" && variableDeclarationNames(stmt, name)) { + return true; + } + } + return false; +} + +function findDescriptorExportLocalBindingName(program: Program): string | null { + for (const stmt of program.body) { + if (stmt.type !== "ExportNamedDeclaration") { + continue; + } + const named = stmt as ExportNamedDeclaration; + if (named.source !== null && named.source !== undefined) { + continue; + } + for (const spec of named.specifiers) { + if (spec.type !== "ExportSpecifier" || exportSpecifierExportedName(spec) !== "descriptor") { + continue; + } + const loc = spec.local; + if (loc.type !== "Identifier") { + return null; + } + return loc.name; + } + } + return null; +} + +function descriptorExportExists(program: Program): boolean { + for (const stmt of program.body) { + if (stmt.type === "ExportNamedDeclaration") { + const named = stmt as ExportNamedDeclaration; + if (namedExportDeclExportsDescriptor(named)) { + return true; + } + } + } + const binding = findDescriptorExportLocalBindingName(program); + if (binding === null) { + return false; + } + return programDeclaresBindingName(program, binding); +} + +function stringLiteralModuleSpecifier(src: Node): string | null { + if (src.type !== "Literal" || typeof (src as AcornNode).value !== "string") { + return null; + } + return (src as AcornNode).value as string; +} + +function validateImportDeclaration(node: ImportDeclaration): string | null { + const spec = stringLiteralModuleSpecifier(node.source); + if (spec === null) { + return "only static string import specifiers are allowed"; + } + if (!isAllowedImportSpecifier(spec)) { + return `disallowed import specifier "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`; + } + return null; +} + +function validateExportSource( + src: Node, + staticMessage: string, + disallowedPrefix: string, +): string | null { + const spec = stringLiteralModuleSpecifier(src); + if (spec === null) { + return staticMessage; + } + if (!isAllowedImportSpecifier(spec)) { + return `${disallowedPrefix} "${spec}" (only Node built-ins and "@uncaged/workflow" are allowed)`; + } + return null; +} + +function validateExportNamedDeclaration(node: ExportNamedDeclaration): string | null { + if (node.source === null || node.source === undefined) { + return null; + } + return validateExportSource( + node.source, + "only static string re-export specifiers are allowed", + "disallowed re-export specifier", + ); +} + +function validateExportAllDeclaration(node: ExportAllDeclaration): string | null { + return validateExportSource( + node.source, + "only static string export-all specifiers are allowed", + "disallowed export-all specifier", + ); +} + +function validateRequireCall(node: CallExpression): string | null { + const callee = node.callee; + if (callee.type === "Identifier" && callee.name === "require") { + return "require() is not allowed in workflow bundles"; + } + return null; +} + +function bundleConstraintViolationForNode(node: Node): string | null { + if (node.type === "ImportExpression") { + return "dynamic import() is not allowed in workflow bundles"; + } + if (node.type === "ImportDeclaration") { + return validateImportDeclaration(narrowNode(node)); + } + if (node.type === "ExportNamedDeclaration") { + return validateExportNamedDeclaration(narrowNode(node)); + } + if (node.type === "ExportAllDeclaration") { + return validateExportAllDeclaration(narrowNode(node)); + } + if (node.type === "CallExpression") { + return validateRequireCall(narrowNode(node)); + } + return null; +} + +/** + * Validate RFC-001 bundle rules: single-file ESM shape, named exports `run` + `descriptor`, + * no default export, no dynamic `import()`, static imports restricted to Node builtins plus `@uncaged/workflow`. + */ +export function validateWorkflowBundle(input: WorkflowBundleValidationInput): Result { + if (!endsWithEsmJs(input.filePath)) { + return err('workflow bundle file must use the ".esm.js" suffix'); + } + + let ast: Node; + try { + ast = acorn.parse(input.source, { + ecmaVersion: 2022, + sourceType: "module", + locations: false, + }) as Node; + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to parse module: ${message}`); + } + + if (ast.type !== "Program") { + return err("internal error: expected Program root"); + } + + const program = ast as Program; + + if (programUsesDefaultExport(program)) { + return err('workflow bundle must not use default export; use "export const run" instead'); + } + + if (!runExportIsCallable(program)) { + return err( + 'workflow bundle must export run as a callable (e.g. "export const run = async function* (...)")', + ); + } + + if (!descriptorExportExists(program)) { + return err( + 'workflow bundle must export descriptor (e.g. "export const descriptor = { description, roles }")', + ); + } + + let violation: string | null = null; + walkAst(ast, (node) => { + if (violation !== null) { + return; + } + const next = bundleConstraintViolationForNode(node); + if (next !== null) { + violation = next; + } + }); + + if (violation !== null) { + return err(violation); + } + + return ok(undefined); +} diff --git a/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts b/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts new file mode 100644 index 0000000..247444b --- /dev/null +++ b/packages/workflow-register/src/bundle/ensure-uncaged-workflow-symlink.ts @@ -0,0 +1,36 @@ +import { mkdir, readlink, symlink, unlink } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** This module lives in `@uncaged/workflow/src/bundle`; grandparent dir is the package root. */ +function installedWorkflowPackageDir(): string { + return fileURLToPath(new URL("../..", import.meta.url)); +} + +/** + * Ensures `/node_modules/@uncaged/workflow` points at the installed `@uncaged/workflow` + * package so workflow bundles loaded from `/bundles/*.esm.js` can resolve `import "@uncaged/workflow"`. + */ +export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise { + const target = installedWorkflowPackageDir(); + const linkDir = path.join(storageRoot, "node_modules", "@uncaged"); + const linkPath = path.join(linkDir, "workflow"); + await mkdir(linkDir, { recursive: true }); + + try { + const existing = await readlink(linkPath); + const normalizedExisting = path.resolve(linkDir, existing); + if (normalizedExisting === target) { + return; + } + await unlink(linkPath); + } catch (e) { + const errObj = e as NodeJS.ErrnoException; + if (errObj.code !== "ENOENT" && errObj.code !== "EINVAL") { + throw e; + } + } + + const linkType = process.platform === "win32" ? "junction" : "dir"; + await symlink(target, linkPath, linkType); +} diff --git a/packages/workflow-register/src/bundle/extract-bundle-exports.ts b/packages/workflow-register/src/bundle/extract-bundle-exports.ts new file mode 100644 index 0000000..8ddf99d --- /dev/null +++ b/packages/workflow-register/src/bundle/extract-bundle-exports.ts @@ -0,0 +1,42 @@ +import type { WorkflowFn } from "@uncaged/workflow-protocol"; +import { err, ok, type Result } from "@uncaged/workflow-util"; +import { importWorkflowBundleModule } from "./bundle-import-env.js"; +import { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js"; +import type { ExtractBundleExportsOptions, ExtractedBundleExports } from "./types.js"; +import { validateWorkflowDescriptor } from "./workflow-descriptor.js"; + +/** Load a workflow `.esm.js` bundle and read its named exports (`run`, `descriptor`). */ +export async function extractBundleExports( + bundlePath: string, + options: ExtractBundleExportsOptions = { storageRoot: null }, +): Promise> { + let modUnknown: unknown; + try { + if (options.storageRoot !== null) { + await ensureUncagedWorkflowSymlink(options.storageRoot); + } + // Dynamic import required: user bundle path resolved at runtime + modUnknown = await importWorkflowBundleModule(bundlePath); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return err(`failed to import bundle: ${message}`); + } + + const modRec = modUnknown as Record; + const defaultExport = modRec.default; + if (defaultExport !== undefined) { + return err("workflow bundle must not use default export; export const run instead"); + } + + const run = modRec.run; + if (typeof run !== "function") { + return err("workflow bundle must export run as a function"); + } + + const validated = validateWorkflowDescriptor(modRec.descriptor); + if (!validated.ok) { + return err(validated.error); + } + + return ok({ run: run as WorkflowFn, descriptor: validated.value }); +} diff --git a/packages/workflow-register/src/bundle/generate-descriptor.ts b/packages/workflow-register/src/bundle/generate-descriptor.ts new file mode 100644 index 0000000..396586a --- /dev/null +++ b/packages/workflow-register/src/bundle/generate-descriptor.ts @@ -0,0 +1,8 @@ +import { stringify } from "yaml"; + +import type { WorkflowDescriptor } from "./types.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-register/src/bundle/index.ts b/packages/workflow-register/src/bundle/index.ts new file mode 100644 index 0000000..f9f7e0e --- /dev/null +++ b/packages/workflow-register/src/bundle/index.ts @@ -0,0 +1,15 @@ +export { buildDescriptor } from "./build-descriptor.js"; +export { importWorkflowBundleModule } from "./bundle-import-env.js"; +export { validateWorkflowBundle } from "./bundle-validator.js"; +export { ensureUncagedWorkflowSymlink } from "./ensure-uncaged-workflow-symlink.js"; +export { extractBundleExports } from "./extract-bundle-exports.js"; +export { stringifyWorkflowDescriptor } from "./generate-descriptor.js"; +export type { + ExtractBundleExportsOptions, + ExtractedBundleExports, + WorkflowBundleValidationInput, + WorkflowDescriptor, + WorkflowRoleDescriptor, + WorkflowRoleSchema, +} from "./types.js"; +export { validateWorkflowDescriptor } from "./workflow-descriptor.js"; diff --git a/packages/workflow-register/src/bundle/types.ts b/packages/workflow-register/src/bundle/types.ts new file mode 100644 index 0000000..020967e --- /dev/null +++ b/packages/workflow-register/src/bundle/types.ts @@ -0,0 +1,25 @@ +import type { WorkflowDescriptor, WorkflowFn } from "@uncaged/workflow-protocol"; + +export type { + WorkflowDescriptor, + WorkflowRoleDescriptor, + WorkflowRoleSchema, + WorkflowFn, +} from "@uncaged/workflow-protocol"; + +export type WorkflowBundleValidationInput = { + /** Absolute or relative path (used for `.esm.js` suffix checks). */ + filePath: string; + /** UTF-8 source of the bundle. */ + source: string; +}; + +export type ExtractedBundleExports = { + run: WorkflowFn; + descriptor: WorkflowDescriptor; +}; + +export type ExtractBundleExportsOptions = { + /** When set, ensures `node_modules/@uncaged/workflow` exists under this root before import. */ + storageRoot: string | null; +}; diff --git a/packages/workflow-register/src/bundle/workflow-descriptor.ts b/packages/workflow-register/src/bundle/workflow-descriptor.ts new file mode 100644 index 0000000..a1941e1 --- /dev/null +++ b/packages/workflow-register/src/bundle/workflow-descriptor.ts @@ -0,0 +1,40 @@ +import { err, ok, type Result } from "@uncaged/workflow-util"; + +import type { WorkflowDescriptor, WorkflowRoleDescriptor, WorkflowRoleSchema } from "./types.js"; + +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-register/src/config/index.ts b/packages/workflow-register/src/config/index.ts new file mode 100644 index 0000000..029a409 --- /dev/null +++ b/packages/workflow-register/src/config/index.ts @@ -0,0 +1,3 @@ +export { resolveModel } from "./resolve-model.js"; +export { splitProviderModelRef } from "./split-provider-model-ref.js"; +export type { ProviderConfig, ResolvedModel } from "./types.js"; diff --git a/packages/workflow-register/src/config/resolve-model.ts b/packages/workflow-register/src/config/resolve-model.ts new file mode 100644 index 0000000..e9d0e5d --- /dev/null +++ b/packages/workflow-register/src/config/resolve-model.ts @@ -0,0 +1,30 @@ +import type { WorkflowConfig } from "@uncaged/workflow-protocol"; +import { err, ok, type Result } from "@uncaged/workflow-util"; +import { splitProviderModelRef } from "./split-provider-model-ref.js"; +import type { ResolvedModel } from "./types.js"; + +/** Resolves scene → provider endpoint + model using {@link WorkflowConfig.providers} and {@link WorkflowConfig.models}. */ +export function resolveModel(config: WorkflowConfig, scene: string): Result { + const models = config.models; + let ref = models[scene] ?? null; + if (ref === null) { + ref = models.default ?? null; + } + if (ref === null) { + return err(`no model mapping for scene "${scene}" and no models.default fallback`); + } + const split = splitProviderModelRef(ref); + if (!split.ok) { + return split; + } + const { providerName, modelName } = split.value; + const provider = config.providers[providerName] ?? null; + if (provider === null) { + return err(`unknown provider "${providerName}" referenced by scene "${scene}"`); + } + return ok({ + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + model: modelName, + }); +} diff --git a/packages/workflow-register/src/config/split-provider-model-ref.ts b/packages/workflow-register/src/config/split-provider-model-ref.ts new file mode 100644 index 0000000..8fa52ce --- /dev/null +++ b/packages/workflow-register/src/config/split-provider-model-ref.ts @@ -0,0 +1,17 @@ +import { err, ok, type Result } from "@uncaged/workflow-util"; + +/** Parses `providerName/modelName` references used in {@link WorkflowConfig.models}. */ +export function splitProviderModelRef( + ref: string, +): Result<{ providerName: string; modelName: string }, string> { + const idx = ref.indexOf("/"); + if (idx <= 0 || idx === ref.length - 1) { + return err(`invalid model reference "${ref}": expected providerName/modelName`); + } + const providerName = ref.slice(0, idx); + const modelName = ref.slice(idx + 1); + if (providerName === "" || modelName === "") { + return err(`invalid model reference "${ref}": expected providerName/modelName`); + } + return ok({ providerName, modelName }); +} diff --git a/packages/workflow-register/src/config/types.ts b/packages/workflow-register/src/config/types.ts new file mode 100644 index 0000000..ca533e4 --- /dev/null +++ b/packages/workflow-register/src/config/types.ts @@ -0,0 +1 @@ +export type { ProviderConfig, ResolvedModel } from "@uncaged/workflow-protocol"; diff --git a/packages/workflow-register/src/index.ts b/packages/workflow-register/src/index.ts new file mode 100644 index 0000000..7bf4be7 --- /dev/null +++ b/packages/workflow-register/src/index.ts @@ -0,0 +1,39 @@ +export { + buildDescriptor, + importWorkflowBundleModule, + validateWorkflowBundle, + ensureUncagedWorkflowSymlink, + extractBundleExports, + stringifyWorkflowDescriptor, + validateWorkflowDescriptor, +} from "./bundle/index.js"; +export type { + ExtractBundleExportsOptions, + ExtractedBundleExports, + WorkflowBundleValidationInput, + WorkflowDescriptor, + WorkflowRoleDescriptor, + WorkflowRoleSchema, +} from "./bundle/index.js"; + +export { + getRegisteredWorkflow, + listRegisteredWorkflowNames, + parseWorkflowRegistryYaml, + readWorkflowRegistry, + registerWorkflowVersion, + rollbackWorkflowToHistoryHash, + stringifyWorkflowRegistryYaml, + unregisterWorkflow, + workflowRegistryPath, + writeWorkflowRegistry, +} from "./registry/index.js"; +export type { + WorkflowConfig, + WorkflowHistoryEntry, + WorkflowRegistryEntry, + WorkflowRegistryFile, +} from "./registry/index.js"; + +export { resolveModel, splitProviderModelRef } from "./config/index.js"; +export type { ProviderConfig, ResolvedModel } from "./config/index.js"; diff --git a/packages/workflow-register/src/registry/index.ts b/packages/workflow-register/src/registry/index.ts new file mode 100644 index 0000000..e200664 --- /dev/null +++ b/packages/workflow-register/src/registry/index.ts @@ -0,0 +1,18 @@ +export { + getRegisteredWorkflow, + listRegisteredWorkflowNames, + parseWorkflowRegistryYaml, + readWorkflowRegistry, + registerWorkflowVersion, + rollbackWorkflowToHistoryHash, + stringifyWorkflowRegistryYaml, + unregisterWorkflow, + workflowRegistryPath, + writeWorkflowRegistry, +} from "./registry.js"; +export type { + WorkflowConfig, + WorkflowHistoryEntry, + WorkflowRegistryEntry, + WorkflowRegistryFile, +} from "./types.js"; diff --git a/packages/workflow-register/src/registry/registry-normalize.ts b/packages/workflow-register/src/registry/registry-normalize.ts new file mode 100644 index 0000000..7d0baa4 --- /dev/null +++ b/packages/workflow-register/src/registry/registry-normalize.ts @@ -0,0 +1,225 @@ +import type { ProviderConfig } from "@uncaged/workflow-protocol"; +import { splitProviderModelRef } from "../config/index.js"; +import { createLogger, err, ok, type Result } from "@uncaged/workflow-util"; +import type { + WorkflowConfig, + WorkflowHistoryEntry, + WorkflowRegistryEntry, + WorkflowRegistryFile, +} from "./types.js"; + +const registryNormalizeLog = createLogger({ sink: { kind: "stderr" } }); + +function resolveRegistryApiKey(raw: string, ctx: string): Result { + if (raw.startsWith("env:")) { + const name = raw.slice("env:".length); + if (name === "") { + return err(new Error(`${ctx}: "env:" apiKey reference must name a variable`)); + } + const value = process.env[name]; + if (value === undefined) { + return err(new Error(`${ctx}: environment variable "${name}" is not set`)); + } + return ok(value); + } + return ok(raw); +} + +function normalizeProviderEntry(name: string, entryRaw: unknown): Result { + if (name === "") { + return err(new Error("config.providers must not contain an empty provider name")); + } + if (entryRaw === null || typeof entryRaw !== "object" || Array.isArray(entryRaw)) { + return err(new Error(`config.providers.${name} must be a mapping`)); + } + const e = entryRaw as Record; + const baseUrl = e.baseUrl; + const apiKeyRaw = e.apiKey; + if (typeof baseUrl !== "string" || baseUrl === "") { + return err(new Error(`config.providers.${name}.baseUrl must be a non-empty string`)); + } + if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") { + return err(new Error(`config.providers.${name}.apiKey must be a non-empty string`)); + } + const apiKeyCtx = `config.providers.${name}.apiKey`; + const apiKeyResult = resolveRegistryApiKey(apiKeyRaw, apiKeyCtx); + if (!apiKeyResult.ok) { + return apiKeyResult; + } + return ok({ baseUrl, apiKey: apiKeyResult.value }); +} + +function normalizeProviders(raw: unknown): Result, Error> { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return err(new Error('registry config must contain a "providers" mapping')); + } + const root = raw as Record; + const providers: Record = {}; + for (const [name, entryRaw] of Object.entries(root)) { + const next = normalizeProviderEntry(name, entryRaw); + if (!next.ok) { + return next; + } + providers[name] = next.value; + } + return ok(providers); +} + +function normalizeModels( + raw: unknown, + providers: Record, +): Result, Error> { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return err(new Error('registry config must contain a "models" mapping')); + } + const root = raw as Record; + const models: Record = {}; + const providerKeys = new Set(Object.keys(providers)); + for (const [scene, refRaw] of Object.entries(root)) { + if (scene === "") { + return err(new Error("config.models must not contain an empty scene name")); + } + if (typeof refRaw !== "string" || refRaw === "") { + return err(new Error(`config.models.${scene} must be a non-empty string (provider/model)`)); + } + const ctx = `config.models.${scene}`; + const parsed = splitProviderModelRef(refRaw); + if (!parsed.ok) { + return err(new Error(`${ctx}: ${parsed.error}`)); + } + if (!providerKeys.has(parsed.value.providerName)) { + return err( + new Error( + `${ctx}: unknown provider "${parsed.value.providerName}" (not listed under config.providers)`, + ), + ); + } + models[scene] = refRaw; + } + if (!Object.hasOwn(models, "default")) { + registryNormalizeLog( + "Z2KP9NWQ", + 'registry config: models mapping has no "default" key; scenes without explicit model mappings may fail at resolveModel', + ); + } + return ok(models); +} + +function normalizeWorkflowConfig(raw: unknown): Result { + if (raw === null || typeof raw !== "object") { + return err(new Error('registry "config" must be a mapping')); + } + const c = raw as Record; + const maxDepth = c.maxDepth; + const supervisorIntervalRaw = c.supervisorInterval; + const providersRaw = c.providers; + const modelsRaw = c.models; + if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) { + return err(new Error("config.maxDepth must be a non-negative integer")); + } + let supervisorInterval = 3; + if (supervisorIntervalRaw !== undefined) { + if ( + typeof supervisorIntervalRaw !== "number" || + !Number.isInteger(supervisorIntervalRaw) || + supervisorIntervalRaw < 0 + ) { + return err(new Error("config.supervisorInterval must be a non-negative integer")); + } + supervisorInterval = supervisorIntervalRaw; + } + const providersResult = normalizeProviders(providersRaw); + if (!providersResult.ok) { + return providersResult; + } + const modelsResult = normalizeModels(modelsRaw, providersResult.value); + if (!modelsResult.ok) { + return modelsResult; + } + return ok({ + maxDepth, + supervisorInterval, + providers: providersResult.value, + models: modelsResult.value, + }); +} + +export function normalizeWorkflowHistoryEntry( + workflowName: string, + index: number, + raw: unknown, +): Result { + if (raw === null || typeof raw !== "object") { + return err(new Error(`workflow "${workflowName}" history[${index}] must be a mapping`)); + } + const he = raw as Record; + const hash = he.hash; + const timestamp = he.timestamp; + if (typeof hash !== "string" || typeof timestamp !== "number" || !Number.isFinite(timestamp)) { + return err( + new Error(`workflow "${workflowName}" history[${index}] must have hash and timestamp`), + ); + } + return ok({ hash, timestamp }); +} + +export function normalizeWorkflowRegistryEntry( + workflowName: string, + raw: unknown, +): Result { + if (raw === null || typeof raw !== "object") { + return err(new Error(`workflow "${workflowName}" must be a mapping`)); + } + const e = raw as Record; + const hash = e.hash; + const timestamp = e.timestamp; + const historyRaw = e.history; + if (typeof hash !== "string") { + return err(new Error(`workflow "${workflowName}" must have a string hash`)); + } + if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) { + return err(new Error(`workflow "${workflowName}" must have a finite numeric timestamp`)); + } + if (!Array.isArray(historyRaw)) { + return err(new Error(`workflow "${workflowName}" must have a history array`)); + } + const history: WorkflowHistoryEntry[] = []; + for (let i = 0; i < historyRaw.length; i++) { + const item = historyRaw[i]; + const next = normalizeWorkflowHistoryEntry(workflowName, i, item); + if (!next.ok) { + return next; + } + history.push(next.value); + } + return ok({ hash, timestamp, history }); +} + +export function normalizeWorkflowRegistryRoot(raw: unknown): Result { + if (raw === null || typeof raw !== "object") { + return err(new Error("registry root must be a mapping")); + } + const root = raw as Record; + const configRaw = root.config; + let config: WorkflowConfig | null = null; + if (configRaw !== undefined && configRaw !== null) { + const configResult = normalizeWorkflowConfig(configRaw); + if (!configResult.ok) { + return configResult; + } + config = configResult.value; + } + const workflowsRaw = root.workflows; + if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") { + return err(new Error('registry must contain a "workflows" mapping')); + } + const workflows: Record = {}; + for (const [name, entryRaw] of Object.entries(workflowsRaw)) { + const entryResult = normalizeWorkflowRegistryEntry(name, entryRaw); + if (!entryResult.ok) { + return entryResult; + } + workflows[name] = entryResult.value; + } + return ok({ config, workflows }); +} diff --git a/packages/workflow-register/src/registry/registry.ts b/packages/workflow-register/src/registry/registry.ts new file mode 100644 index 0000000..0dc04a2 --- /dev/null +++ b/packages/workflow-register/src/registry/registry.ts @@ -0,0 +1,144 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { parseDocument, stringify } from "yaml"; +import { err, ok, type Result } from "@uncaged/workflow-util"; +import { normalizeWorkflowRegistryRoot } from "./registry-normalize.js"; +import type { WorkflowHistoryEntry, WorkflowRegistryEntry, WorkflowRegistryFile } from "./types.js"; + +export function workflowRegistryPath(storageRoot: string): string { + return join(storageRoot, "workflow.yaml"); +} + +function emptyRegistry(): WorkflowRegistryFile { + return { config: null, workflows: {} }; +} + +export function parseWorkflowRegistryYaml(text: string): Result { + if (text.trim() === "") { + return ok(emptyRegistry()); + } + let doc: unknown; + try { + doc = parseDocument(text).toJSON(); + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))); + } + return normalizeWorkflowRegistryRoot(doc); +} + +export function stringifyWorkflowRegistryYaml(registry: WorkflowRegistryFile): string { + return `${stringify(registry, { indent: 2, defaultStringType: "QUOTE_DOUBLE" })}\n`; +} + +export async function readWorkflowRegistry( + storageRoot: string, +): Promise> { + const path = workflowRegistryPath(storageRoot); + let text: string; + try { + text = await readFile(path, "utf8"); + } catch (e) { + const errObj = e as NodeJS.ErrnoException; + if (errObj.code === "ENOENT") { + return ok(emptyRegistry()); + } + return err(errObj instanceof Error ? errObj : new Error(String(e))); + } + return parseWorkflowRegistryYaml(text); +} + +export async function writeWorkflowRegistry( + storageRoot: string, + registry: WorkflowRegistryFile, +): Promise> { + const path = workflowRegistryPath(storageRoot); + try { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, stringifyWorkflowRegistryYaml(registry), "utf8"); + } catch (e) { + return err(e instanceof Error ? e : new Error(String(e))); + } + return ok(undefined); +} + +export function listRegisteredWorkflowNames(registry: WorkflowRegistryFile): string[] { + return Object.keys(registry.workflows).sort(); +} + +export function getRegisteredWorkflow( + registry: WorkflowRegistryFile, + name: string, +): WorkflowRegistryEntry | null { + const entry = registry.workflows[name]; + if (entry === undefined) { + return null; + } + return entry; +} + +/** Register or upgrade a workflow version, moving the previous head into `history`. */ +export function registerWorkflowVersion( + registry: WorkflowRegistryFile, + name: string, + hash: string, + timestamp: number, +): WorkflowRegistryFile { + const prev = registry.workflows[name]; + const baseHistory = prev === undefined ? [] : prev.history; + const history: WorkflowHistoryEntry[] = + prev === undefined + ? baseHistory + : [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory]; + const next: WorkflowRegistryEntry = { hash, timestamp, history }; + return { + config: registry.config, + workflows: { ...registry.workflows, [name]: next }, + }; +} + +/** + * Roll back `entry` to a hash listed in `entry.history`. + * When `targetHash` is null, uses the most recent history entry (`history[0]`). + * Current head is prepended to history; the selected entry becomes the new head. + */ +export function rollbackWorkflowToHistoryHash( + entry: WorkflowRegistryEntry, + targetHash: string | null, +): Result { + const resolved = + targetHash !== null && targetHash !== "" + ? targetHash + : entry.history[0] !== undefined + ? entry.history[0].hash + : null; + if (resolved === null) { + return err(new Error("no history entry to rollback to")); + } + const idx = entry.history.findIndex((h) => h.hash === resolved); + if (idx < 0) { + return err(new Error(`hash not found in history: ${resolved}`)); + } + const selected = entry.history[idx]; + const newHistory: WorkflowHistoryEntry[] = [ + { hash: entry.hash, timestamp: entry.timestamp }, + ...entry.history.slice(0, idx), + ...entry.history.slice(idx + 1), + ]; + return ok({ + hash: selected.hash, + timestamp: selected.timestamp, + history: newHistory, + }); +} + +export function unregisterWorkflow( + registry: WorkflowRegistryFile, + name: string, +): Result { + if (registry.workflows[name] === undefined) { + return err(new Error(`workflow not registered: ${name}`)); + } + const { [name]: _removed, ...rest } = registry.workflows; + return ok({ config: registry.config, workflows: rest }); +} diff --git a/packages/workflow-register/src/registry/types.ts b/packages/workflow-register/src/registry/types.ts new file mode 100644 index 0000000..e314d60 --- /dev/null +++ b/packages/workflow-register/src/registry/types.ts @@ -0,0 +1,19 @@ +import type { WorkflowConfig } from "@uncaged/workflow-protocol"; + +export type { WorkflowConfig } from "@uncaged/workflow-protocol"; + +export type WorkflowHistoryEntry = { + hash: string; + timestamp: number; +}; + +export type WorkflowRegistryEntry = { + hash: string; + timestamp: number; + history: WorkflowHistoryEntry[]; +}; + +export type WorkflowRegistryFile = { + config: WorkflowConfig | null; + workflows: Record; +}; diff --git a/packages/workflow-register/tsconfig.json b/packages/workflow-register/tsconfig.json new file mode 100644 index 0000000..7ec8f89 --- /dev/null +++ b/packages/workflow-register/tsconfig.json @@ -0,0 +1,25 @@ +{ + "references": [ + { "path": "../workflow-protocol" }, + { "path": "../workflow-util" } + ], + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"] +}