From 7db43005deba786ed0e878b8050ccbfdf123fc36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sun, 7 Jun 2026 15:08:36 +0000 Subject: [PATCH] fix(cli): align workflow list with thread start via parent traversal discoverProjectWorkflows() now searches .workflow/ in ancestor directories, matching findWorkflowInParents() behavior used by `uwf thread start`. Also documents project-local .workflow/ auto-discovery in usage, cli, and workflow-authoring references. Fixes #162 --- .changeset/fix-162-workflow-list-recursive.md | 19 ++ packages/cli/src/__tests__/prompt.test.ts | 30 +++ .../__tests__/workflow-list-recursive.test.ts | 225 ++++++++++++++++++ packages/cli/src/store.ts | 58 +++-- packages/util/src/cli-reference.ts | 17 +- packages/util/src/usage-reference.ts | 21 +- .../util/src/workflow-authoring-reference.ts | 22 ++ 7 files changed, 369 insertions(+), 23 deletions(-) create mode 100644 .changeset/fix-162-workflow-list-recursive.md create mode 100644 packages/cli/src/__tests__/workflow-list-recursive.test.ts diff --git a/.changeset/fix-162-workflow-list-recursive.md b/.changeset/fix-162-workflow-list-recursive.md new file mode 100644 index 0000000..2b36fa6 --- /dev/null +++ b/.changeset/fix-162-workflow-list-recursive.md @@ -0,0 +1,19 @@ +--- +"@united-workforce/cli": patch +"@united-workforce/util": patch +--- + +fix(cli): align `uwf workflow list` with `uwf thread start` parent traversal; document `.workflow/` auto-discovery (#162) + +`discoverProjectWorkflows()` now walks from `cwd` up through parent directories +looking for the nearest `.workflow/` (or legacy `.workflows/`), mirroring +`findWorkflowInParents()` used by `uwf thread start`. Previously, `uwf workflow +list` only inspected the exact `cwd` and returned `[]` when run from any +subdirectory, even though `uwf thread start ` succeeded from the same +location. The two commands now agree on what is discoverable. + +The `@united-workforce/util` reference strings (`generateUsageReference`, +`generateCliReference`, `generateWorkflowAuthoringReference`) are updated to +document project-local `.workflow/` auto-discovery and recommend it as the +primary placement strategy — `uwf workflow add` registration is only needed for +global, cwd-independent workflows. diff --git a/packages/cli/src/__tests__/prompt.test.ts b/packages/cli/src/__tests__/prompt.test.ts index 21d5288..76089bb 100644 --- a/packages/cli/src/__tests__/prompt.test.ts +++ b/packages/cli/src/__tests__/prompt.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest"; const __dirname = dirname(fileURLToPath(import.meta.url)); +import { generateCliReference } from "@united-workforce/util"; import { cmdPromptAdapterDeveloping, cmdPromptBootstrap, @@ -42,6 +43,24 @@ describe("prompt commands", () => { expect(result.length).toBeGreaterThan(500); }); + test("prompt usage describes .workflow/ auto-discovery", () => { + const result = cmdPromptUsage(); + expect(result).toContain(".workflow/"); + expect(result).toContain("uwf thread start solve-issue"); + expect(result.toLowerCase()).toContain("auto-discover"); + expect(result.toLowerCase()).toContain("recommended"); + }); + + test("prompt cli-reference describes .workflow/ auto-discovery", () => { + const ref = generateCliReference(); + expect(ref).toContain(".workflow/"); + expect(ref.toLowerCase()).toContain("cwd upward"); + expect(ref).toContain("workflow list"); + expect(ref).toMatch(/CAS hash/i); + expect(ref).toMatch(/file path/i); + expect(ref).toMatch(/registry/i); + }); + test("prompt workflow-authoring returns non-empty markdown string with frontmatter", () => { const result = cmdPromptWorkflowAuthoring(); expect(typeof result).toBe("string"); @@ -56,6 +75,17 @@ describe("prompt commands", () => { expect(result.length).toBeGreaterThan(500); }); + test("prompt workflow-authoring documents .workflow/ Placement section", () => { + const result = cmdPromptWorkflowAuthoring(); + expect(result).toContain("## Placement"); + expect(result).toContain(".workflow/"); + expect(result).toContain("solve-issue.yaml"); + expect(result.toLowerCase()).toContain("auto-discover"); + expect(result.toLowerCase()).toContain("no workflow add"); + // Placement must appear before Self-Testing + expect(result.indexOf("## Placement")).toBeLessThan(result.indexOf("## Self-Testing")); + }); + test("prompt adapter-developing returns non-empty markdown string with frontmatter", () => { const result = cmdPromptAdapterDeveloping(); expect(typeof result).toBe("string"); diff --git a/packages/cli/src/__tests__/workflow-list-recursive.test.ts b/packages/cli/src/__tests__/workflow-list-recursive.test.ts new file mode 100644 index 0000000..6421906 --- /dev/null +++ b/packages/cli/src/__tests__/workflow-list-recursive.test.ts @@ -0,0 +1,225 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CasRef, WorkflowPayload } from "@united-workforce/protocol"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { stringify } from "yaml"; +import { cmdThreadStart } from "../commands/thread.js"; +import { cmdWorkflowList } from "../commands/workflow.js"; +import type { UwfStore } from "../store.js"; +import { createUwfStore, discoverProjectWorkflows } from "../store.js"; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +async function makeUwfStore(storageRoot: string): Promise { + const casDir = join(storageRoot, "cas"); + await mkdir(casDir, { recursive: true }); + process.env.OCAS_HOME = casDir; + return createUwfStore(storageRoot); +} + +function makeMinimalPayload(name: string, description: string): WorkflowPayload { + return { + name, + description, + roles: { + worker: { + description: "worker role", + goal: "do work", + capabilities: [], + procedure: "", + output: "", + frontmatter: { + type: "object", + properties: { + $status: { const: "done" }, + }, + required: ["$status"], + } as unknown as CasRef, + }, + }, + graph: { + $START: { + new: { role: "worker", prompt: "start working", location: null }, + resume: { role: "worker", prompt: "resume working", location: null }, + }, + worker: { done: { role: "$END", prompt: "done", location: null } }, + }, + }; +} + +async function createWorkflowYaml(name: string, version: string | null = null): Promise { + const payload = makeMinimalPayload( + name, + version !== null ? `Test workflow (${version})` : "Test workflow", + ); + return stringify(payload); +} + +// ── fixture ─────────────────────────────────────────────────────────────────── + +let tmpDir: string; +let storageRoot: string; +let projectRoot: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "uwf-wf-list-recursive-")); + storageRoot = join(tmpDir, "storage"); + projectRoot = join(tmpDir, "project"); + await mkdir(storageRoot, { recursive: true }); + await mkdir(projectRoot, { recursive: true }); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +// ── discoverProjectWorkflows — parent traversal ─────────────────────────────── + +describe("discoverProjectWorkflows — parent traversal", () => { + test("B1: finds workflows in cwd's .workflow/", async () => { + const wfDir = join(projectRoot, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue")); + + const entries = await discoverProjectWorkflows(projectRoot); + + expect(entries.map((e) => e.name)).toContain("solve-issue"); + }); + + test("B2: finds workflows in ancestor's .workflow/ when called from subdirectory", async () => { + const wfDir = join(projectRoot, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue")); + + const subdir = join(projectRoot, "packages", "cli", "src"); + await mkdir(subdir, { recursive: true }); + + const entries = await discoverProjectWorkflows(subdir); + + expect(entries.map((e) => e.name)).toContain("solve-issue"); + }); + + test("B3: returns [] when no .workflow/ exists in any ancestor", async () => { + // Use a deep path under tmpDir that has no .workflow/ on the way up. + // (Traversal will stop at filesystem root and find nothing.) + const deepPath = join(tmpDir, "isolated", "no", "workflow", "here"); + await mkdir(deepPath, { recursive: true }); + + const entries = await discoverProjectWorkflows(deepPath); + + expect(entries).toEqual([]); + }); + + test("B4: .workflow/ entries win over .workflows/ within the same directory", async () => { + const wfDir = join(projectRoot, ".workflow"); + const legacyDir = join(projectRoot, ".workflows"); + await mkdir(wfDir, { recursive: true }); + await mkdir(legacyDir, { recursive: true }); + + await writeFile( + join(wfDir, "solve-issue.yaml"), + await createWorkflowYaml("solve-issue", "new"), + ); + await writeFile( + join(legacyDir, "solve-issue.yaml"), + await createWorkflowYaml("solve-issue", "legacy"), + ); + + const entries = await discoverProjectWorkflows(projectRoot); + + const match = entries.find((e) => e.name === "solve-issue"); + expect(match).toBeDefined(); + expect(match?.filePath).toBe(join(wfDir, "solve-issue.yaml")); + }); + + test("B5: nearest .workflow/ wins over ancestor's .workflow/", async () => { + const ancestorWf = join(projectRoot, ".workflow"); + await mkdir(ancestorWf, { recursive: true }); + await writeFile(join(ancestorWf, "foo.yaml"), await createWorkflowYaml("foo", "ancestor")); + + const nearDir = join(projectRoot, "pkg"); + const nearWf = join(nearDir, ".workflow"); + await mkdir(nearWf, { recursive: true }); + await writeFile(join(nearWf, "foo.yaml"), await createWorkflowYaml("foo", "near")); + + const entries = await discoverProjectWorkflows(nearDir); + + const match = entries.find((e) => e.name === "foo"); + expect(match).toBeDefined(); + expect(match?.filePath).toBe(join(nearWf, "foo.yaml")); + // Should not include duplicates from ancestor + expect(entries.filter((e) => e.name === "foo")).toHaveLength(1); + }); + + test("B6: returns all entries from the nearest .workflow/ when called from a deep subdir", async () => { + const wfDir = join(projectRoot, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue")); + await writeFile(join(wfDir, "review-code.yaml"), await createWorkflowYaml("review-code")); + + const deep = join(projectRoot, "a", "b", "c", "d"); + await mkdir(deep, { recursive: true }); + + const entries = await discoverProjectWorkflows(deep); + + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["review-code", "solve-issue"]); + }); + + test("B7: discovers folder-based layout (name/index.yaml) via parent traversal", async () => { + const folderDir = join(projectRoot, ".workflow", "solve-issue"); + await mkdir(folderDir, { recursive: true }); + await writeFile(join(folderDir, "index.yaml"), await createWorkflowYaml("solve-issue")); + + const subdir = join(projectRoot, "deep", "sub"); + await mkdir(subdir, { recursive: true }); + + const entries = await discoverProjectWorkflows(subdir); + + const match = entries.find((e) => e.name === "solve-issue"); + expect(match).toBeDefined(); + expect(match?.filePath).toBe(join(folderDir, "index.yaml")); + }); +}); + +// ── cmdWorkflowList — parent traversal ─────────────────────────────────────── + +describe("cmdWorkflowList — parent traversal", () => { + test("B9: lists local workflows discovered from a subdirectory", async () => { + await makeUwfStore(storageRoot); + const wfDir = join(projectRoot, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue")); + + const subdir = join(projectRoot, "packages", "foo", "src"); + await mkdir(subdir, { recursive: true }); + + const result = await cmdWorkflowList(storageRoot, subdir); + + const match = result.find((e) => e.name === "solve-issue"); + expect(match).toBeDefined(); + expect(match?.hash).toBe("(local)"); + expect(match?.origin).toBe("local"); + }); + + test("aligns with cmdThreadStart discovery from same subdirectory", async () => { + await makeUwfStore(storageRoot); + const wfDir = join(projectRoot, ".workflow"); + await mkdir(wfDir, { recursive: true }); + await writeFile(join(wfDir, "foo.yaml"), await createWorkflowYaml("foo")); + + const subdir = join(projectRoot, "packages", "foo", "src"); + await mkdir(subdir, { recursive: true }); + + // cmdThreadStart already resolves foo successfully from subdir (existing behavior) + const startResult = await cmdThreadStart(storageRoot, "foo", "prompt", subdir); + expect(startResult.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + + // cmdWorkflowList must ALSO include foo (newly aligned behavior) + const listResult = await cmdWorkflowList(storageRoot, subdir); + const match = listResult.find((e) => e.name === "foo"); + expect(match).toBeDefined(); + expect(match?.origin).toBe("local"); + }); +}); diff --git a/packages/cli/src/store.ts b/packages/cli/src/store.ts index a45dd5b..c2d6dab 100644 --- a/packages/cli/src/store.ts +++ b/packages/cli/src/store.ts @@ -2,7 +2,7 @@ import type { Dirent } from "node:fs"; import { existsSync } from "node:fs"; import { access, mkdir, readdir, readFile, rename } from "node:fs/promises"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve as resolvePath } from "node:path"; import { bootstrap, type Hash, type Store, type VarStore } from "@ocas/core"; import { createFsStore, createSqliteVarStore } from "@ocas/fs"; @@ -83,23 +83,51 @@ async function scanWorkflowDir(dir: string): Promise { } /** - * Scan `/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries. - * .workflow/ takes priority: if a name is found in both, .workflow/ wins. - * Returns an empty array if neither directory exists. + * Discover project-local workflows by walking from `startDir` up through parent + * directories. The nearest directory that contains a `.workflow/` or `.workflows/` + * directory wins — once a match is found, traversal stops (entries from more + * distant ancestors are NOT merged in). + * + * Within the winning directory: + * - `.workflow/` (preferred) takes priority over `.workflows/` (legacy). + * - If both exist in that directory, `.workflow/` entries win when names collide. + * + * This matches the resolution strategy of `findWorkflowInParents` used by + * `uwf thread start`, so `uwf workflow list` and `uwf thread start` agree on + * what's discoverable from any given subdirectory. + * + * Returns an empty array if no `.workflow/` or `.workflows/` directory exists + * anywhere from `startDir` up to the filesystem root. */ -export async function discoverProjectWorkflows( - projectRoot: string, -): Promise { - const primary = await scanWorkflowDir(join(projectRoot, ".workflow")); - const legacy = await scanWorkflowDir(join(projectRoot, ".workflows")); - const seen = new Set(primary.map((e) => e.name)); - const merged = [...primary]; - for (const entry of legacy) { - if (!seen.has(entry.name)) { - merged.push(entry); +export async function discoverProjectWorkflows(startDir: string): Promise { + let currentDir = resolvePath(startDir); + const root = resolvePath("/"); + + while (true) { + const primary = await scanWorkflowDir(join(currentDir, ".workflow")); + const legacy = await scanWorkflowDir(join(currentDir, ".workflows")); + + if (primary.length > 0 || legacy.length > 0) { + const seen = new Set(primary.map((e) => e.name)); + const merged = [...primary]; + for (const entry of legacy) { + if (!seen.has(entry.name)) { + merged.push(entry); + } + } + return merged; } + + // Stop at filesystem root + if (currentDir === root) { + return []; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + return []; + } + currentDir = parentDir; } - return merged; } /** Default filesystem root for uwf data (`~/.uwf`). */ diff --git a/packages/util/src/cli-reference.ts b/packages/util/src/cli-reference.ts index 54cb1c1..7fcc291 100644 --- a/packages/util/src/cli-reference.ts +++ b/packages/util/src/cli-reference.ts @@ -17,9 +17,24 @@ uwf setup --provider --base-url \\ \`\`\` uwf workflow add # register a workflow from YAML file uwf workflow show # show workflow by name or CAS hash -uwf workflow list # list all registered workflows +uwf workflow list # list workflows (auto-discovers .workflow/ from cwd upward + global registry) \`\`\` +### Workflow Resolution + +\`uwf thread start \` and \`uwf workflow list\` both resolve the workflow +argument by searching from cwd upward. Strategies are tried in priority order: + +1. **CAS hash** — a 13-char Crockford Base32 string is loaded directly from CAS. +2. **File path** — a relative or absolute \`.yaml\`/\`.yml\` path is materialized on the fly. +3. **Local \`.workflow/\` (cwd upward)** — \`uwf\` searches from cwd upward for the nearest + directory containing \`.workflow/.yaml\`, \`.workflow/.yml\`, + \`.workflow//index.yaml\`, or the legacy \`.workflows/\` variants. \`workflow list\` + uses the same cwd upward parent traversal so its output matches what \`thread start\` + can resolve. +4. **Global registry** — \`uwf workflow add\` stores the workflow under + \`@uwf/registry/\` for system-wide resolution independent of cwd. + ## Thread Commands \`\`\` diff --git a/packages/util/src/usage-reference.ts b/packages/util/src/usage-reference.ts index a0dfc9f..6c6b30f 100644 --- a/packages/util/src/usage-reference.ts +++ b/packages/util/src/usage-reference.ts @@ -18,11 +18,14 @@ Guide for using the uwf CLI to manage workflows and threads. # 1. Configure provider and model uwf setup -# 2. Register a workflow -uwf workflow add my-workflow.yaml +# 2. Place a workflow under .workflow/ in your project (recommended) +# uwf thread start auto-discovers from .workflow/ by walking from cwd upward. +# No workflow add registration needed. +mkdir -p .workflow +cp my-workflow.yaml .workflow/solve-issue.yaml -# 3. Start a thread (creates but does not execute) -uwf thread start my-workflow -p "Build a login page" +# 3. Start a thread by bare name (no file path) +uwf thread start solve-issue -p "Build a login page" # 4. Execute the thread (runs moderator → agent → extract cycles) uwf thread exec # one step @@ -51,12 +54,16 @@ Config is stored at \`~/.uwf/config.yaml\`. Override storage root with \`UWF_HOM ## Workflow Commands \`\`\` -uwf workflow add # register from YAML file +uwf workflow add # register from YAML file (optional) uwf workflow show # show by name or CAS hash -uwf workflow list # list all registered workflows +uwf workflow list # list workflows (auto-discovers .workflow/ from cwd upward + global registry) \`\`\` -You can also pass a file path directly to \`uwf thread start\` without registering first. +Three placement strategies, in priority order: + +1. **Project-local \`.workflow/\` (recommended)** — drop \`.yaml\` (or \`/index.yaml\`) under \`/.workflow/\`. \`uwf thread start \` and \`uwf workflow list\` both auto-discover by walking from cwd upward. No registration step is needed. +2. **Explicit file path** — pass a relative or absolute \`.yaml\` path to \`uwf thread start ./path/to/workflow.yaml\`. Useful for one-off runs and testing. +3. **Global registry** — \`uwf workflow add \` stores the workflow hash under \`@uwf/registry/\` so it is available system-wide, independent of cwd. ## Thread Lifecycle diff --git a/packages/util/src/workflow-authoring-reference.ts b/packages/util/src/workflow-authoring-reference.ts index 4a28e67..e1ca825 100644 --- a/packages/util/src/workflow-authoring-reference.ts +++ b/packages/util/src/workflow-authoring-reference.ts @@ -159,6 +159,28 @@ graph: failed: { role: cleanup, prompt: "Clean up: {{{error}}}" } \`\`\` +## Placement + +Drop your workflow YAML under a project-local \`.workflow/\` directory at (or above) +your repo root: + +\`\`\` +my-project/ + .workflow/ + solve-issue.yaml + review-code.yaml +\`\`\` + +\`uwf thread start solve-issue\` will auto-discover \`.workflow/solve-issue.yaml\` by +searching from cwd upward — you can run the command from any subdirectory of the +project. \`uwf workflow list\` uses the same parent traversal, so its output +matches what \`thread start\` can resolve. No workflow add registration needed — +\`uwf workflow add\` is only required for global, cwd-independent registration. + +Folder-based layouts also work — \`.workflow//index.yaml\` (or \`index.yml\`) is +discovered as workflow \`\`. The legacy \`.workflows/\` directory remains +supported as a fallback when \`.workflow/\` is absent. + ## Self-Testing ### Step-by-Step Verification