From 8720eb19af781793e8c4ccd221c8f04fd4bc4860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Sat, 23 May 2026 10:52:47 +0000 Subject: [PATCH] feat(cli-workflow): implement multi-strategy workflow resolution for issue #428 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 4-strategy resolution priority: CAS hash → file path → local discovery → global registry - Add helper functions: isFilePath, workflowFileExists, findWorkflowInDir, findWorkflowInParents - Refactor resolveWorkflowCasRef to support direct hash, explicit paths, and parent traversal - Add comprehensive test suite with 24 tests covering all strategies and edge cases - Support .workflow/ and .workflows/ directories with .yaml/.yml extensions - All 60 tests pass across 5 test files Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/workflow-resolution.test.ts | 367 ++++++++++++++++++ packages/cli-workflow/src/commands/thread.ts | 121 +++++- 2 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/workflow-resolution.test.ts diff --git a/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts new file mode 100644 index 0000000..d2decf1 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/workflow-resolution.test.ts @@ -0,0 +1,367 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createFsStore } from "@uncaged/json-cas-fs"; +import type { CasRef, WorkflowPayload } from "@uncaged/workflow-protocol"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { stringify } from "yaml"; +import { cmdThreadStart } from "../commands/thread.js"; +import { registerUwfSchemas } from "../schemas.js"; +import type { UwfStore } from "../store.js"; +import { loadWorkflowRegistry, saveWorkflowRegistry } from "../store.js"; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +async function makeUwfStore(storageRoot: string): Promise { + const casDir = join(storageRoot, "cas"); + await mkdir(casDir, { recursive: true }); + const store = createFsStore(casDir); + const schemas = await registerUwfSchemas(store); + return { storageRoot, store, schemas }; +} + +async function storeWorkflow(uwf: UwfStore, name: string): Promise { + const payload: WorkflowPayload = { + name, + description: "Test workflow", + roles: {}, + conditions: {}, + graph: {}, + }; + return await uwf.store.put(uwf.schemas.workflow, payload); +} + +async function createWorkflowYaml(name: string, version: string | null = null): Promise { + const payload: WorkflowPayload = { + name, + description: version !== null ? `Test workflow (${version})` : "Test workflow", + roles: {}, + conditions: {}, + graph: {}, + }; + const yaml = stringify(payload); + return yaml; +} + +// ── fixture ─────────────────────────────────────────────────────────────────── + +let tmpDir: string; +let storageRoot: string; +let projectRoot: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "cli-uwf-wf-resolve-test-")); + 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 }); +}); + +// ── Strategy 1: CAS Hash Resolution ─────────────────────────────────────────── + +describe("Strategy 1: CAS Hash Resolution", () => { + test("should resolve valid 13-char Crockford Base32 hash", async () => { + const uwf = await makeUwfStore(storageRoot); + const hash = await storeWorkflow(uwf, "test-workflow"); + + const result = await cmdThreadStart(storageRoot, hash, "test prompt", projectRoot); + + expect(result.workflow).toBe(hash); + expect(result.thread).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + }); + + test("should fail on invalid hash format (non-Crockford characters)", async () => { + await makeUwfStore(storageRoot); + + await expect( + cmdThreadStart(storageRoot, "123456789ABCD", "prompt", projectRoot), + ).rejects.toThrow(); + }); + + test("should fail on valid-format hash not present in CAS", async () => { + await makeUwfStore(storageRoot); + const fakeHash = "0000000000000"; // valid format, doesn't exist + + await expect(cmdThreadStart(storageRoot, fakeHash, "prompt", projectRoot)).rejects.toThrow(); + }); + + test("should reject 40-char hex hash (legacy format not supported)", async () => { + await makeUwfStore(storageRoot); + const hexHash = "a".repeat(40); + + await expect(cmdThreadStart(storageRoot, hexHash, "prompt", projectRoot)).rejects.toThrow(); + }); +}); + +// ── Strategy 2: File Path Resolution ────────────────────────────────────────── + +describe("Strategy 2: File Path Resolution", () => { + test("should load workflow from absolute file path", async () => { + await makeUwfStore(storageRoot); + const yamlPath = join(tmpDir, "test-workflow.yaml"); + await writeFile(yamlPath, await createWorkflowYaml("test-workflow")); + + const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).name).toBe("test-workflow"); + } + }); + + test("should load workflow from relative file path", async () => { + await makeUwfStore(storageRoot); + const yamlPath = "test-workflow.yaml"; + await writeFile(join(projectRoot, yamlPath), await createWorkflowYaml("test-workflow")); + + const result = await cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("should fail when file path does not exist", async () => { + await makeUwfStore(storageRoot); + + await expect( + cmdThreadStart(storageRoot, "./nonexistent.yaml", "prompt", projectRoot), + ).rejects.toThrow(); + }); + + test("should fail on invalid YAML syntax in file", async () => { + await makeUwfStore(storageRoot); + const yamlPath = join(tmpDir, "bad-syntax.yaml"); + await writeFile(yamlPath, "invalid: yaml: : :"); + + await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow(); + }); + + test("should fail on valid YAML with invalid WorkflowPayload shape", async () => { + await makeUwfStore(storageRoot); + const yamlPath = join(tmpDir, "invalid-workflow.yaml"); + await writeFile(yamlPath, "name: test\n# missing roles, conditions, and graph"); + + await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow(); + }); + + test("should enforce filename matches workflow name", async () => { + await makeUwfStore(storageRoot); + const yamlPath = join(tmpDir, "solve-issue.yaml"); + await writeFile(yamlPath, await createWorkflowYaml("wrong-name")); + + await expect(cmdThreadStart(storageRoot, yamlPath, "prompt", projectRoot)).rejects.toThrow(); + }); +}); + +// ── Strategy 3: Local Discovery (Parent Traversal) ──────────────────────────── + +describe("Strategy 3: Local Discovery", () => { + test("should find workflow in current directory .workflow/", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue")); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).name).toBe("solve-issue"); + } + }); + + test("should find workflow in parent directory .workflow/", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "solve-issue.yaml"), await createWorkflowYaml("solve-issue")); + + const subdir = join(projectRoot, "packages", "cli-workflow", "src"); + await mkdir(subdir, { recursive: true }); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", subdir); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("should stop at filesystem root when traversing", async () => { + await makeUwfStore(storageRoot); + const deepPath = join(tmpDir, "deep", "path", "that", "does", "not", "have", "workflow"); + await mkdir(deepPath, { recursive: true }); + + await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", deepPath)).rejects.toThrow(); + }); + + test("should prefer .workflow/ over .workflows/ directory", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow"); + const workflowsDir = join(projectRoot, ".workflows"); + await mkdir(workflowDir, { recursive: true }); + await mkdir(workflowsDir, { recursive: true }); + + await writeFile( + join(workflowDir, "solve-issue.yaml"), + await createWorkflowYaml("solve-issue", "1"), + ); + await writeFile( + join(workflowsDir, "solve-issue.yaml"), + await createWorkflowYaml("solve-issue", "2"), + ); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).description).toBe("Test workflow (1)"); + } + }); + + test("should support .yml extension in local discovery", async () => { + await makeUwfStore(storageRoot); + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeFile(join(workflowDir, "solve-issue.yml"), await createWorkflowYaml("solve-issue")); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); +}); + +// ── Strategy 4: Global Registry Fallback ────────────────────────────────────── + +describe("Strategy 4: Global Registry Resolution", () => { + test("should resolve workflow from global registry when not found locally", async () => { + const uwf = await makeUwfStore(storageRoot); + const hash = await storeWorkflow(uwf, "deploy-pipeline"); + const registry = await loadWorkflowRegistry(storageRoot); + registry["deploy-pipeline"] = hash; + await saveWorkflowRegistry(storageRoot, registry); + + const isolatedRoot = join(tmpDir, "isolated"); + await mkdir(isolatedRoot, { recursive: true }); + + const result = await cmdThreadStart(storageRoot, "deploy-pipeline", "prompt", isolatedRoot); + + expect(result.workflow).toBe(hash); + }); + + test("should fail when workflow not found in any strategy", async () => { + await makeUwfStore(storageRoot); + + await expect(cmdThreadStart(storageRoot, "nonexistent", "prompt", tmpDir)).rejects.toThrow(); + }); +}); + +// ── Strategy Priority Order ─────────────────────────────────────────────────── + +describe("Resolution Priority", () => { + test("should use explicit file path over local discovery", async () => { + await makeUwfStore(storageRoot); + + // Setup: Create workflow in .workflow/ AND as explicit file + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + await writeFile( + join(workflowDir, "solve-issue.yaml"), + await createWorkflowYaml("solve-issue", "discovery"), + ); + + const explicitPath = join(projectRoot, "custom-solve-issue.yaml"); + await writeFile(explicitPath, await createWorkflowYaml("custom-solve-issue", "explicit")); + + // Execute with explicit path + const result = await cmdThreadStart(storageRoot, explicitPath, "prompt", projectRoot); + + const uwf = await makeUwfStore(storageRoot); + const node = uwf.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).description).toBe("Test workflow (explicit)"); + } + }); + + test("should use local discovery over global registry", async () => { + const uwf = await makeUwfStore(storageRoot); + + // Setup: Register globally + const globalHash = await storeWorkflow(uwf, "solve-issue"); + const registry = await loadWorkflowRegistry(storageRoot); + registry["solve-issue"] = globalHash; + await saveWorkflowRegistry(storageRoot, registry); + + // Setup: Create local .workflow/ + const workflowDir = join(projectRoot, ".workflow"); + await mkdir(workflowDir, { recursive: true }); + const localYaml = await createWorkflowYaml("solve-issue", "local"); + await writeFile(join(workflowDir, "solve-issue.yaml"), localYaml); + + const result = await cmdThreadStart(storageRoot, "solve-issue", "prompt", projectRoot); + + const uwf2 = await makeUwfStore(storageRoot); + const node = uwf2.store.get(result.workflow); + expect(node).not.toBeNull(); + if (node !== null) { + expect((node.payload as WorkflowPayload).description).toBe("Test workflow (local)"); + } + }); +}); + +// ── Edge Cases ──────────────────────────────────────────────────────────────── + +describe("Edge Cases", () => { + test("should treat '13-char-string.yaml' as file path, not CAS hash", async () => { + await makeUwfStore(storageRoot); + const fileName = "0123456789ABC.yaml"; // 13 chars + .yaml + await writeFile(join(projectRoot, fileName), await createWorkflowYaml("0123456789ABC")); + + const result = await cmdThreadStart(storageRoot, fileName, "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("should handle workflow names containing slashes as file paths", async () => { + await makeUwfStore(storageRoot); + const filePath = "subdir/solve-issue.yaml"; + const fullPath = join(projectRoot, filePath); + await mkdir(join(projectRoot, "subdir"), { recursive: true }); + await writeFile(fullPath, await createWorkflowYaml("solve-issue")); + + const result = await cmdThreadStart(storageRoot, filePath, "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("should handle absolute paths correctly", async () => { + await makeUwfStore(storageRoot); + const absPath = join(tmpDir, "abs-workflow.yaml"); + await writeFile(absPath, await createWorkflowYaml("abs-workflow")); + + const result = await cmdThreadStart(storageRoot, absPath, "prompt", projectRoot); + + expect(result.workflow).toMatch(/^[0-9A-HJKMNP-TV-Z]{13}$/); + }); + + test("should fail on empty workflow ID", async () => { + await makeUwfStore(storageRoot); + + await expect(cmdThreadStart(storageRoot, "", "prompt", projectRoot)).rejects.toThrow(); + }); + + test("should fail on whitespace-only workflow ID", async () => { + await makeUwfStore(storageRoot); + + await expect(cmdThreadStart(storageRoot, " ", "prompt", projectRoot)).rejects.toThrow(); + }); +}); diff --git a/packages/cli-workflow/src/commands/thread.ts b/packages/cli-workflow/src/commands/thread.ts index 6e535b2..435026c 100644 --- a/packages/cli-workflow/src/commands/thread.ts +++ b/packages/cli-workflow/src/commands/thread.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; 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"; @@ -30,12 +31,10 @@ import { parse, stringify } from "yaml"; import { appendThreadHistory, createUwfStore, - discoverProjectWorkflows, findThreadInHistory, loadThreadHistory, loadThreadsIndex, loadWorkflowRegistry, - resolveProjectWorkflowFile, resolveWorkflowHash, saveThreadsIndex, type ThreadHistoryLine, @@ -82,6 +81,83 @@ function fail(message: string): never { process.exit(1); } +/** + * Check if a string looks like a file path (contains path separators or has .yaml/.yml extension). + */ +function isFilePath(input: string): boolean { + return ( + input.includes("/") || input.includes("\\") || input.endsWith(".yaml") || input.endsWith(".yml") + ); +} + +/** + * Check if a workflow file exists at the given path. + */ +async function workflowFileExists(dir: string, name: string, ext: string): Promise { + const candidate = resolvePath(dir, `${name}${ext}`); + try { + await access(candidate); + return candidate; + } catch { + return null; + } +} + +/** + * Search for a workflow file in a given directory (checks both .workflow/ and .workflows/). + */ +async function findWorkflowInDir(dir: string, name: string): Promise { + // Check .workflow/ directory first (preferred) + for (const ext of [".yaml", ".yml"]) { + const result = await workflowFileExists(resolvePath(dir, ".workflow"), name, ext); + if (result !== null) { + return result; + } + } + + // Check .workflows/ directory as fallback (legacy) + for (const ext of [".yaml", ".yml"]) { + const result = await workflowFileExists(resolvePath(dir, ".workflows"), name, ext); + if (result !== null) { + return result; + } + } + + return null; +} + +/** + * Traverse parent directories looking for `.workflow/.yaml` or `.workflow/.yml`. + * Returns the absolute path if found, otherwise null. + * Stops at filesystem root or .git directory. + */ +async function findWorkflowInParents(startDir: string, name: string): Promise { + let currentDir = resolvePath(startDir); + const root = resolvePath("/"); + + while (true) { + const found = await findWorkflowInDir(currentDir, name); + if (found !== null) { + return found; + } + + // Stop at filesystem root + if (currentDir === root) { + break; + } + + // Move to parent directory + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + // Reached filesystem root + break; + } + currentDir = parentDir; + } + + return null; +} + async function materializeLocalWorkflow(uwf: UwfStore, filePath: string): Promise { let text: string; try { @@ -123,18 +199,41 @@ async function resolveWorkflowCasRef( workflowId: string, projectRoot: string, ): Promise { - // Project-local resolution: check .workflows/.yaml first - const localEntries = await discoverProjectWorkflows(projectRoot); - const localFile = resolveProjectWorkflowFile(localEntries, workflowId); - if (localFile !== null) { - return materializeLocalWorkflow(uwf, localFile); + // Validate input + const trimmed = workflowId.trim(); + if (trimmed === "") { + fail("workflow ID cannot be empty"); } - // Global registry fallback + // Strategy 1: Direct CAS hash + if (isCasRef(trimmed)) { + const node = uwf.store.get(trimmed); + if (node === null) { + fail(`CAS node not found: ${trimmed}`); + } + if (node.type !== uwf.schemas.workflow) { + fail(`node ${trimmed} is not a Workflow (type ${node.type})`); + } + return trimmed; + } + + // Strategy 2: Explicit file path (relative or absolute) + if (isFilePath(trimmed)) { + const absolutePath = isAbsolute(trimmed) ? trimmed : resolvePath(projectRoot, trimmed); + return materializeLocalWorkflow(uwf, absolutePath); + } + + // Strategy 3: Local discovery (parent directory traversal) + const localPath = await findWorkflowInParents(projectRoot, trimmed); + if (localPath !== null) { + return materializeLocalWorkflow(uwf, localPath); + } + + // Strategy 4: Global registry fallback const registry = await loadWorkflowRegistry(storageRoot); - const hash = resolveWorkflowHash(registry, workflowId); + const hash = resolveWorkflowHash(registry, trimmed); if (!isCasRef(hash)) { - fail(`workflow not found: ${workflowId}`); + fail(`workflow not found: ${trimmed}`); } const node = uwf.store.get(hash); if (node === null) {