From e59ae9aca14d21668a393d001cd49fac157e3eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 22 May 2026 01:01:45 +0000 Subject: [PATCH] feat: support project-local workflow discovery - Add .workflows/*.yaml scanning from project root (cwd) - Resolution: project-local first, then global registry - On-the-fly CAS materialization for local workflows - Filename/name consistency check - uwf workflow list shows origin (local/global) Fixes #365 --- packages/cli-workflow/src/cli.ts | 9 ++- packages/cli-workflow/src/commands/thread.ts | 55 +++++++++++++++++- .../cli-workflow/src/commands/workflow.ts | 52 +++++++++++------ packages/cli-workflow/src/store.ts | 56 ++++++++++++++++++- packages/cli-workflow/src/validate.ts | 28 ++++++++++ 5 files changed, 176 insertions(+), 24 deletions(-) diff --git a/packages/cli-workflow/src/cli.ts b/packages/cli-workflow/src/cli.ts index 995fb67..1a6a5c6 100755 --- a/packages/cli-workflow/src/cli.ts +++ b/packages/cli-workflow/src/cli.ts @@ -47,7 +47,10 @@ const program = new Command(); // eslint-disable-next-line -- dynamic import for version const pkg = await import("../package.json", { with: { type: "json" } }); -program.name("uwf").description("Stateless workflow CLI").version(pkg.default.version, "-V, --version"); +program + .name("uwf") + .description("Stateless workflow CLI") + .version(pkg.default.version, "-V, --version"); program.option("--format ", "Output format: json or yaml", "json"); const workflow = program.command("workflow").description("Workflow registry and CAS"); @@ -82,7 +85,7 @@ workflow .action(() => { const storageRoot = resolveStorageRoot(); runAction(async () => { - const result = await cmdWorkflowList(storageRoot); + const result = await cmdWorkflowList(storageRoot, process.cwd()); writeOutput(result); }); }); @@ -97,7 +100,7 @@ thread .action((workflow: string, opts: { prompt: string }) => { const storageRoot = resolveStorageRoot(); runAction(async () => { - const result = await cmdThreadStart(storageRoot, workflow, opts.prompt); + const result = await cmdThreadStart(storageRoot, workflow, opts.prompt, process.cwd()); writeOutput(result); }); }); diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index dde7a64..e7146be 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -1,4 +1,5 @@ import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; import type { Store as CasStore, JSONSchema } from "@uncaged/json-cas"; import { getSchema, validate } from "@uncaged/json-cas"; import { getEnvPath, loadWorkflowConfig } from "@uncaged/workflow-agent-kit"; @@ -24,21 +25,24 @@ import type { } from "@uncaged/workflow-protocol"; import { generateUlid } from "@uncaged/workflow-util"; import { config as loadDotenv } from "dotenv"; -import { stringify } from "yaml"; +import { parse, stringify } from "yaml"; import { appendThreadHistory, createUwfStore, + discoverProjectWorkflows, findThreadInHistory, loadThreadHistory, loadThreadsIndex, loadWorkflowRegistry, + resolveProjectWorkflowFile, resolveWorkflowHash, saveThreadsIndex, type ThreadHistoryLine, type UwfStore, } from "../store.js"; -import { isCasRef } from "../validate.js"; +import { checkWorkflowFilenameConsistency, isCasRef, parseWorkflowPayload } from "../validate.js"; +import { materializeWorkflowPayload } from "./workflow.js"; const END_ROLE = "$END"; export const THREAD_READ_DEFAULT_QUOTA = 4000; @@ -66,11 +70,55 @@ function fail(message: string): never { process.exit(1); } +async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise { + let text: string; + try { + text = await readFile(filePath, "utf8"); + } catch { + fail(`project workflow file not found: ${filePath}`); + } + + let raw: unknown; + try { + raw = parse(text) as unknown; + } catch (e) { + fail(`invalid YAML in ${filePath}: ${e instanceof Error ? e.message : String(e)}`); + } + + const payload = parseWorkflowPayload(raw); + if (payload === null) { + fail(`invalid workflow YAML in ${filePath}: expected WorkflowPayload shape`); + } + + const filenameError = checkWorkflowFilenameConsistency(filePath, payload); + if (filenameError !== null) { + fail(filenameError); + } + + const materialized = await materializeWorkflowPayload(uwf, payload); + const hash = await uwf.store.put(uwf.schemas.workflow, materialized); + const stored = uwf.store.get(hash); + if (stored === null || !validate(uwf.store, stored)) { + fail("stored local workflow failed schema validation"); + } + + return hash; +} + async function resolveWorkflowCasRef( uwf: UwfStore, storageRoot: string, workflowId: string, + projectRoot: string, ): Promise { + // Project-local resolution: check .workflows/.yaml first + const localEntries = await discoverProjectWorkflows(projectRoot); + const localFile = resolveProjectWorkflowFile(localEntries, workflowId); + if (localFile !== null) { + return materializeLocalWorkflow(uwf, localFile); + } + + // Global registry fallback const registry = await loadWorkflowRegistry(storageRoot); const hash = resolveWorkflowHash(registry, workflowId); if (!isCasRef(hash)) { @@ -114,9 +162,10 @@ export async function cmdThreadStart( storageRoot: string, workflowId: string, prompt: string, + projectRoot: string, ): Promise { const uwf = await createUwfStore(storageRoot); - const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId); + const workflowHash = await resolveWorkflowCasRef(uwf, storageRoot, workflowId, projectRoot); const threadId = generateUlid(Date.now()) as ThreadId; const startPayload: StartNodePayload = { diff --git a/packages/cli-workflow/src/commands/workflow.ts b/packages/cli-workflow/src/commands/workflow.ts index 56a9377..2618481 100644 --- a/packages/cli-workflow/src/commands/workflow.ts +++ b/packages/cli-workflow/src/commands/workflow.ts @@ -7,17 +7,21 @@ import { parse } from "yaml"; import { createUwfStore, + discoverProjectWorkflows, findRegistryName, loadWorkflowRegistry, resolveWorkflowHash, saveWorkflowRegistry, type UwfStore, } from "../store.js"; -import { parseWorkflowPayload } from "../validate.js"; +import { checkWorkflowFilenameConsistency, parseWorkflowPayload } from "../validate.js"; + +export type WorkflowOrigin = "local" | "global"; export type WorkflowListEntry = { name: string; hash: CasRef; + origin: WorkflowOrigin; }; export type WorkflowPutOutput = { @@ -42,31 +46,21 @@ function isJsonSchema(value: unknown): value is JSONSchema { return typeof value === "object" && value !== null && !Array.isArray(value); } -async function resolveMetaRef( - uwf: UwfStore, - roleName: string, - meta: unknown, -): Promise { +async function resolveMetaRef(uwf: UwfStore, roleName: string, meta: unknown): Promise { if (!isJsonSchema(meta)) { fail(`role "${roleName}": meta must be a JSON Schema object`); } - const schema: JSONSchema = meta.title === undefined - ? { ...meta, title: roleName } - : meta; + const schema: JSONSchema = meta.title === undefined ? { ...meta, title: roleName } : meta; return putSchema(uwf.store, schema); } -async function materializeWorkflowPayload( +export async function materializeWorkflowPayload( uwf: UwfStore, raw: WorkflowPayload, ): Promise { const roles: Record = {}; for (const [roleName, role] of Object.entries(raw.roles)) { - const meta = await resolveMetaRef( - uwf, - `${raw.name}.${roleName}`, - role.meta, - ); + const meta = await resolveMetaRef(uwf, `${raw.name}.${roleName}`, role.meta); roles[roleName] = { description: role.description, goal: role.goal, @@ -108,6 +102,11 @@ export async function cmdWorkflowPut( 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); @@ -150,7 +149,26 @@ export async function cmdWorkflowShow( }; } -export async function cmdWorkflowList(storageRoot: string): Promise { +export async function cmdWorkflowList( + storageRoot: string, + projectRoot: string, +): Promise { + const localEntries = await discoverProjectWorkflows(projectRoot); const registry = await loadWorkflowRegistry(storageRoot); - return Object.entries(registry).map(([name, hash]) => ({ name, hash })); + + const result: WorkflowListEntry[] = []; + const localNames = new Set(); + + 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; } diff --git a/packages/cli-workflow/src/store.ts b/packages/cli-workflow/src/store.ts index ecc5462..ba73db4 100644 --- a/packages/cli-workflow/src/store.ts +++ b/packages/cli-workflow/src/store.ts @@ -1,4 +1,4 @@ -import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -11,6 +11,44 @@ import { registerUwfSchemas, type UwfSchemaHashes } from "./schemas.js"; export type WorkflowRegistry = Record; +/** A workflow entry discovered from the project-local .workflows/ directory. */ +export type ProjectWorkflowEntry = { + /** Workflow name (from YAML `name` field, equals filename stem). */ + name: string; + /** Absolute path to the YAML file. */ + filePath: string; +}; + +/** + * Scan `/.workflows/*.yaml` (non-recursive) and return discovered entries. + * Returns an empty array if the directory does not exist. + */ +export async function discoverProjectWorkflows( + projectRoot: string, +): Promise { + const dir = join(projectRoot, ".workflows"); + let entries: string[]; + try { + entries = await readdir(dir); + } catch (e) { + const err = e as NodeJS.ErrnoException; + if (err.code === "ENOENT" || err.code === "ENOTDIR") { + return []; + } + throw e; + } + + const result: ProjectWorkflowEntry[] = []; + for (const entry of entries) { + if (!entry.endsWith(".yaml") && !entry.endsWith(".yml")) { + continue; + } + const stem = entry.endsWith(".yaml") ? entry.slice(0, -5) : entry.slice(0, -4); + result.push({ name: stem, filePath: join(dir, entry) }); + } + return result; +} + /** Default filesystem root for uwf data (`~/.uncaged/workflow`). */ export function getDefaultStorageRoot(): string { return join(homedir(), ".uncaged", "workflow"); @@ -104,6 +142,22 @@ export function resolveWorkflowHash(registry: WorkflowRegistry, id: string): Cas return registry[id] !== undefined ? registry[id] : id; } +/** + * Resolve a workflow name to a project-local YAML file path. + * Returns null if the name is not found in the local entries. + */ +export function resolveProjectWorkflowFile( + localEntries: ProjectWorkflowEntry[], + name: string, +): string | null { + for (const entry of localEntries) { + if (entry.name === name) { + return entry.filePath; + } + } + return null; +} + export function findRegistryName(registry: WorkflowRegistry, hash: Hash): string | null { for (const [name, h] of Object.entries(registry)) { if (h === hash) { diff --git a/packages/cli-workflow/src/validate.ts b/packages/cli-workflow/src/validate.ts index a82032c..a7a3edb 100644 --- a/packages/cli-workflow/src/validate.ts +++ b/packages/cli-workflow/src/validate.ts @@ -1,3 +1,4 @@ +import { basename } from "node:path"; import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol"; const CAS_REF_PATTERN = /^[0-9A-HJKMNP-TV-Z]{13}$/; @@ -60,6 +61,33 @@ function isGraph(value: unknown): boolean { ); } +/** + * Derive the expected workflow name from a file path (stem without extension). + * Returns the stem for `.yaml` / `.yml` files. + */ +export function workflowNameFromPath(filePath: string): string { + const base = basename(filePath); + if (base.endsWith(".yaml")) return base.slice(0, -5); + if (base.endsWith(".yml")) return base.slice(0, -4); + return base; +} + +/** + * Check that the `name` field in a parsed payload matches the expected name + * derived from the file path. Returns an error message string on mismatch, + * or null when the names are consistent. + */ +export function checkWorkflowFilenameConsistency( + filePath: string, + payload: WorkflowPayload, +): string | null { + const expected = workflowNameFromPath(filePath); + if (payload.name !== expected) { + return `workflow name mismatch: file "${basename(filePath)}" implies name "${expected}" but YAML declares name "${payload.name}"`; + } + return null; +} + /** Validate YAML-parsed workflow document shape (outputSchema may be inline JSON Schema). */ export function parseWorkflowPayload(raw: unknown): WorkflowPayload | null { if (!isRecord(raw)) {