fix(cli): align workflow list with thread start via parent traversal
CI / check (pull_request) Successful in 3m12s
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:
@@ -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
@@ -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`). */
|
||||
|
||||
Reference in New Issue
Block a user