fix(cli): workflow list parent traversal + docs for .workflow/ auto-discovery #167

Merged
scottwei merged 1 commits from fix/162-workflow-list-recursive into main 2026-06-07 15:23:18 +00:00
7 changed files with 369 additions and 23 deletions
@@ -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 <name>` 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.
+30
View File
@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
import { generateCliReference } from "@united-workforce/util";
import { import {
cmdPromptAdapterDeveloping, cmdPromptAdapterDeveloping,
cmdPromptBootstrap, cmdPromptBootstrap,
@@ -42,6 +43,24 @@ describe("prompt commands", () => {
expect(result.length).toBeGreaterThan(500); 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", () => { test("prompt workflow-authoring returns non-empty markdown string with frontmatter", () => {
const result = cmdPromptWorkflowAuthoring(); const result = cmdPromptWorkflowAuthoring();
expect(typeof result).toBe("string"); expect(typeof result).toBe("string");
@@ -56,6 +75,17 @@ describe("prompt commands", () => {
expect(result.length).toBeGreaterThan(500); 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", () => { test("prompt adapter-developing returns non-empty markdown string with frontmatter", () => {
const result = cmdPromptAdapterDeveloping(); const result = cmdPromptAdapterDeveloping();
expect(typeof result).toBe("string"); expect(typeof result).toBe("string");
@@ -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<UwfStore> {
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<string> {
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");
});
});
+37 -9
View File
@@ -2,7 +2,7 @@ import type { Dirent } from "node:fs";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { access, mkdir, readdir, readFile, rename } from "node:fs/promises"; import { access, mkdir, readdir, readFile, rename } from "node:fs/promises";
import { homedir } from "node:os"; 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 { bootstrap, type Hash, type Store, type VarStore } from "@ocas/core";
import { createFsStore, createSqliteVarStore } from "@ocas/fs"; import { createFsStore, createSqliteVarStore } from "@ocas/fs";
@@ -83,15 +83,31 @@ async function scanWorkflowDir(dir: string): Promise<ProjectWorkflowEntry[]> {
} }
/** /**
* Scan `<projectRoot>/.workflow/` (preferred) and `.workflows/` (legacy) for workflow entries. * Discover project-local workflows by walking from `startDir` up through parent
* .workflow/ takes priority: if a name is found in both, .workflow/ wins. * directories. The nearest directory that contains a `.workflow/` or `.workflows/`
* Returns an empty array if neither directory exists. * 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( export async function discoverProjectWorkflows(startDir: string): Promise<ProjectWorkflowEntry[]> {
projectRoot: string, let currentDir = resolvePath(startDir);
): Promise<ProjectWorkflowEntry[]> { const root = resolvePath("/");
const primary = await scanWorkflowDir(join(projectRoot, ".workflow"));
const legacy = await scanWorkflowDir(join(projectRoot, ".workflows")); 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 seen = new Set(primary.map((e) => e.name));
const merged = [...primary]; const merged = [...primary];
for (const entry of legacy) { for (const entry of legacy) {
@@ -100,6 +116,18 @@ export async function discoverProjectWorkflows(
} }
} }
return merged; return merged;
}
// Stop at filesystem root
if (currentDir === root) {
return [];
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) {
return [];
}
currentDir = parentDir;
}
} }
/** Default filesystem root for uwf data (`~/.uwf`). */ /** Default filesystem root for uwf data (`~/.uwf`). */
+16 -1
View File
@@ -17,9 +17,24 @@ uwf setup --provider <name> --base-url <url> \\
\`\`\` \`\`\`
uwf workflow add <file> # register a workflow from YAML file uwf workflow add <file> # register a workflow from YAML file
uwf workflow show <id> # show workflow by name or CAS hash uwf workflow show <id> # 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 <workflow>\` 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/<name>.yaml\`, \`.workflow/<name>.yml\`,
\`.workflow/<name>/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/<name>\` for system-wide resolution independent of cwd.
## Thread Commands ## Thread Commands
\`\`\` \`\`\`
+14 -7
View File
@@ -18,11 +18,14 @@ Guide for using the uwf CLI to manage workflows and threads.
# 1. Configure provider and model # 1. Configure provider and model
uwf setup uwf setup
# 2. Register a workflow # 2. Place a workflow under .workflow/ in your project (recommended)
uwf workflow add my-workflow.yaml # 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) # 3. Start a thread by bare name (no file path)
uwf thread start my-workflow -p "Build a login page" uwf thread start solve-issue -p "Build a login page"
# 4. Execute the thread (runs moderator → agent → extract cycles) # 4. Execute the thread (runs moderator → agent → extract cycles)
uwf thread exec <thread-id> # one step uwf thread exec <thread-id> # one step
@@ -51,12 +54,16 @@ Config is stored at \`~/.uwf/config.yaml\`. Override storage root with \`UWF_HOM
## Workflow Commands ## Workflow Commands
\`\`\` \`\`\`
uwf workflow add <file> # register from YAML file uwf workflow add <file> # register from YAML file (optional)
uwf workflow show <id> # show by name or CAS hash uwf workflow show <id> # 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 \`<name>.yaml\` (or \`<name>/index.yaml\`) under \`<repo>/.workflow/\`. \`uwf thread start <name>\` 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 <file>\` stores the workflow hash under \`@uwf/registry/<name>\` so it is available system-wide, independent of cwd.
## Thread Lifecycle ## Thread Lifecycle
@@ -159,6 +159,28 @@ graph:
failed: { role: cleanup, prompt: "Clean up: {{{error}}}" } 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/<name>/index.yaml\` (or \`index.yml\`) is
discovered as workflow \`<name>\`. The legacy \`.workflows/\` directory remains
supported as a fallback when \`.workflow/\` is absent.
## Self-Testing ## Self-Testing
### Step-by-Step Verification ### Step-by-Step Verification