Files
united-workforce/packages/cli-workflow/src/commands/workflow.ts
T
xiaoju e67932c83c fix: accept omitted condition in fallback transitions
Fallback transitions (last entry in graph node) omit the condition
field in YAML, resulting in undefined instead of null. The validator
and materializer now handle this:

- validate.ts: accept undefined as valid condition value
- workflow.ts: normalizeGraph() coerces undefined → null before CAS put

This was broken by the graph fallback pattern introduced in #370.
2026-05-22 07:38:24 +00:00

201 lines
5.2 KiB
TypeScript

import { readFile } from "node:fs/promises";
import type { JSONSchema } from "@uncaged/json-cas";
import { putSchema, validate } from "@uncaged/json-cas";
import type {
CasRef,
RoleDefinition,
Transition,
WorkflowPayload,
} from "@uncaged/workflow-protocol";
import { parse } from "yaml";
import {
createUwfStore,
discoverProjectWorkflows,
findRegistryName,
loadWorkflowRegistry,
resolveWorkflowHash,
saveWorkflowRegistry,
type UwfStore,
} from "../store.js";
import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js";
export type WorkflowOrigin = "local" | "global";
export type WorkflowListEntry = {
name: string;
hash: CasRef;
origin: WorkflowOrigin;
};
export type WorkflowPutOutput = {
name: string;
hash: CasRef;
};
export type WorkflowShowOutput = {
hash: CasRef;
name: string | null;
type: CasRef;
payload: WorkflowPayload;
timestamp: number;
};
function fail(message: string): never {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function isJsonSchema(value: unknown): value is JSONSchema {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Normalize graph transitions: ensure condition is null (not undefined) for fallback entries. */
function normalizeGraph(graph: Record<string, Transition[]>): Record<string, Transition[]> {
const result: Record<string, Transition[]> = {};
for (const [node, transitions] of Object.entries(graph)) {
result[node] = transitions.map((t) => ({
role: t.role,
condition: t.condition ?? null,
}));
}
return result;
}
async function resolveFrontmatterRef(
uwf: UwfStore,
roleName: string,
frontmatter: unknown,
): Promise<CasRef> {
if (!isJsonSchema(frontmatter)) {
fail(`role "${roleName}": frontmatter must be a JSON Schema object`);
}
const schema: JSONSchema =
frontmatter.title === undefined ? { ...frontmatter, title: roleName } : frontmatter;
return putSchema(uwf.store, schema);
}
export async function materializeWorkflowPayload(
uwf: UwfStore,
raw: WorkflowPayload,
): Promise<WorkflowPayload> {
const roles: Record<string, RoleDefinition> = {};
for (const [roleName, role] of Object.entries(raw.roles)) {
const frontmatter = await resolveFrontmatterRef(
uwf,
`${raw.name}.${roleName}`,
role.frontmatter,
);
roles[roleName] = {
description: role.description,
goal: role.goal,
capabilities: role.capabilities,
procedure: role.procedure,
output: role.output,
frontmatter,
};
}
return {
name: raw.name,
description: raw.description,
roles,
conditions: raw.conditions,
graph: normalizeGraph(raw.graph),
};
}
export async function cmdWorkflowPut(
storageRoot: string,
filePath: string,
): Promise<WorkflowPutOutput> {
let text: string;
try {
text = await readFile(filePath, "utf8");
} catch {
fail(`file not found: ${filePath}`);
}
let raw: unknown;
try {
raw = parse(text) as unknown;
} catch (e) {
fail(`invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
const payload = parseWorkflowPayload(raw);
if (payload === null) {
fail("invalid workflow YAML: expected WorkflowPayload shape");
}
const filenameError = checkWorkflowFilenameConsistency(filePath, payload);
if (filenameError !== null) {
fail(filenameError);
}
const uwf = await createUwfStore(storageRoot);
const materialized = await materializeWorkflowPayload(uwf, payload);
const hash = await uwf.store.put(uwf.schemas.workflow, materialized);
const node = uwf.store.get(hash);
if (node === null || !validate(uwf.store, node)) {
fail("stored workflow failed schema validation");
}
const registry = await loadWorkflowRegistry(storageRoot);
registry[materialized.name] = hash;
await saveWorkflowRegistry(storageRoot, registry);
return { name: materialized.name, hash };
}
export async function cmdWorkflowShow(
storageRoot: string,
id: string,
): Promise<WorkflowShowOutput> {
const uwf = await createUwfStore(storageRoot);
const registry = await loadWorkflowRegistry(storageRoot);
const hash = resolveWorkflowHash(registry, id);
const node = uwf.store.get(hash);
if (node === null) {
fail(`CAS node not found: ${hash}`);
}
if (node.type !== uwf.schemas.workflow) {
fail(`node ${hash} is not a Workflow (type ${node.type})`);
}
const payload = node.payload as WorkflowPayload;
return {
hash,
name: findRegistryName(registry, hash),
type: node.type,
payload,
timestamp: node.timestamp,
};
}
export async function cmdWorkflowList(
storageRoot: string,
projectRoot: string,
): Promise<WorkflowListEntry[]> {
const localEntries = await discoverProjectWorkflows(projectRoot);
const registry = await loadWorkflowRegistry(storageRoot);
const result: WorkflowListEntry[] = [];
const localNames = new Set<string>();
for (const entry of localEntries) {
localNames.add(entry.name);
result.push({ name: entry.name, hash: "(local)", origin: "local" });
}
for (const [name, hash] of Object.entries(registry)) {
if (!localNames.has(name)) {
result.push({ name, hash, origin: "global" });
}
}
return result;
}