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
This commit is contained in:
@@ -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 <fmt>", "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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CasRef> {
|
||||
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<CasRef> {
|
||||
// Project-local resolution: check .workflows/<workflowId>.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<StartOutput> {
|
||||
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 = {
|
||||
|
||||
@@ -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<CasRef> {
|
||||
async function resolveMetaRef(uwf: UwfStore, roleName: string, meta: unknown): Promise<CasRef> {
|
||||
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<WorkflowPayload> {
|
||||
const roles: Record<string, RoleDefinition> = {};
|
||||
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<WorkflowListEntry[]> {
|
||||
export async function cmdWorkflowList(
|
||||
storageRoot: string,
|
||||
projectRoot: string,
|
||||
): Promise<WorkflowListEntry[]> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
@@ -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<string, CasRef>;
|
||||
|
||||
/** 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 `<projectRoot>/.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<ProjectWorkflowEntry[]> {
|
||||
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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user