fix(cli): align workflow list with thread start via parent traversal
CI / check (pull_request) Successful in 3m12s

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
This commit is contained in:
2026-06-07 15:08:36 +00:00
parent 2f7609683a
commit 7db43005de
7 changed files with 369 additions and 23 deletions
+30
View File
@@ -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");
@@ -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");
});
});
+43 -15
View File
@@ -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<ProjectWorkflowEntry[]> {
}
/**
* Scan `<projectRoot>/.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<ProjectWorkflowEntry[]> {
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<ProjectWorkflowEntry[]> {
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`). */