feat(register): create @uncaged/workflow-register package

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
This commit is contained in:
2026-05-09 11:16:27 +08:00
parent 1a1e8b3398
commit b07f8cf166
20 changed files with 1167 additions and 0 deletions
+26
View File
@@ -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"
}
}
@@ -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<string, unknown>): WorkflowRoleSchema {
const { $schema: _drop, ...rest } = json;
return rest as WorkflowRoleSchema;
}
export function buildDescriptor<M extends RoleMeta>(
def: WorkflowDefinition<M>,
): 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<string, unknown>;
roles[key] = {
description: roleDef.description,
schema: stripJsonSchemaMeta(rawJsonSchema),
};
}
return { description: def.description, roles };
}
@@ -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<unknown> {
return import(pathToFileURL(bundlePath).href);
}
@@ -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<T extends Node>(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<ImportDeclaration>(node));
}
if (node.type === "ExportNamedDeclaration") {
return validateExportNamedDeclaration(narrowNode<ExportNamedDeclaration>(node));
}
if (node.type === "ExportAllDeclaration") {
return validateExportAllDeclaration(narrowNode<ExportAllDeclaration>(node));
}
if (node.type === "CallExpression") {
return validateRequireCall(narrowNode<CallExpression>(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<void, string> {
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);
}
@@ -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 `<storageRoot>/node_modules/@uncaged/workflow` points at the installed `@uncaged/workflow`
* package so workflow bundles loaded from `<storageRoot>/bundles/*.esm.js` can resolve `import "@uncaged/workflow"`.
*/
export async function ensureUncagedWorkflowSymlink(storageRoot: string): Promise<void> {
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);
}
@@ -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<Result<ExtractedBundleExports, string>> {
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<string, unknown>;
const defaultExport = modRec.default;
if (defaultExport !== undefined) {
return err("workflow bundle must not use default export; export const run instead");
}
const run = modRec.run;
if (typeof run !== "function") {
return err("workflow bundle must export run as a function");
}
const validated = validateWorkflowDescriptor(modRec.descriptor);
if (!validated.ok) {
return err(validated.error);
}
return ok({ run: run as WorkflowFn, descriptor: validated.value });
}
@@ -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" });
}
@@ -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";
@@ -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;
};
@@ -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<WorkflowDescriptor, string> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return err("descriptor must be a non-array object");
}
const root = value as Record<string, unknown>;
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<string, WorkflowRoleDescriptor> = {};
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<string, unknown>;
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 });
}
@@ -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";
@@ -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<ResolvedModel, string> {
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,
});
}
@@ -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 });
}
@@ -0,0 +1 @@
export type { ProviderConfig, ResolvedModel } from "@uncaged/workflow-protocol";
+39
View File
@@ -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";
@@ -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";
@@ -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<string, Error> {
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<ProviderConfig, Error> {
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<string, unknown>;
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<Record<string, ProviderConfig>, 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<string, unknown>;
const providers: Record<string, ProviderConfig> = {};
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<string, ProviderConfig>,
): Result<Record<string, string>, 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<string, unknown>;
const models: Record<string, string> = {};
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<WorkflowConfig, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error('registry "config" must be a mapping'));
}
const c = raw as Record<string, unknown>;
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<WorkflowHistoryEntry, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error(`workflow "${workflowName}" history[${index}] must be a mapping`));
}
const he = raw as Record<string, unknown>;
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<WorkflowRegistryEntry, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error(`workflow "${workflowName}" must be a mapping`));
}
const e = raw as Record<string, unknown>;
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<WorkflowRegistryFile, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error("registry root must be a mapping"));
}
const root = raw as Record<string, unknown>;
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<string, WorkflowRegistryEntry> = {};
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 });
}
@@ -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<WorkflowRegistryFile, Error> {
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<Result<WorkflowRegistryFile, Error>> {
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<Result<void, Error>> {
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<WorkflowRegistryEntry, Error> {
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<WorkflowRegistryFile, Error> {
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 });
}
@@ -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<string, WorkflowRegistryEntry>;
};
+25
View File
@@ -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"]
}