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:
@@ -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";
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user