From c0cefc48fb35583255b72f866933501ad09aea3d 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 --- .../cli-workflow/src/__tests__/log.test.ts | 6 +- .../src/__tests__/workflow-resolution.test.ts | 367 ++++++++++++++++++ packages/cli-workflow/src/commands/setup.ts | 9 +- packages/cli-workflow/src/commands/thread.ts | 121 +++++- .../__tests__/path.test.ts | 2 +- .../src/tools/run-command.ts | 3 +- .../__tests__/session-detail.test.ts | 11 +- .../src/claude-code.ts | 11 +- .../workflow-agent-hermes/src/acp-client.ts | 3 +- packages/workflow-agent-kit/src/index.ts | 2 +- .../workflow-agent-kit/src/session-cache.ts | 2 +- packages/workflow-dashboard/index.html | 4 +- packages/workflow-dashboard/server.ts | 3 - packages/workflow-dashboard/server/api.ts | 6 +- .../workflow-dashboard/server/workflow.ts | 10 +- .../src/components/ui/button.tsx | 14 +- .../src/components/ui/card.tsx | 41 +- .../src/components/ui/dialog.tsx | 81 ++-- .../src/components/ui/input.tsx | 12 +- .../src/components/ui/label.tsx | 10 +- .../src/components/ui/separator.tsx | 18 +- .../src/components/ui/textarea.tsx | 10 +- .../workflow-dashboard/src/editor/context.tsx | 53 ++- .../src/editor/edges/conditional.tsx | 63 +-- .../src/editor/edges/index.tsx | 2 +- .../workflow-dashboard/src/editor/flow.tsx | 39 +- .../src/editor/injection.ts | 21 +- .../src/editor/layout/index.ts | 22 +- .../src/editor/model/add-node-view.ts | 31 +- .../src/editor/model/edges.ts | 33 +- .../src/editor/model/edit-node-view.ts | 18 +- .../src/editor/model/handlers.ts | 52 +-- .../src/editor/model/index.ts | 12 +- .../src/editor/model/inject.ts | 5 +- .../src/editor/model/nodes.ts | 41 +- .../src/editor/nodes/end.tsx | 14 +- .../src/editor/nodes/index.tsx | 6 +- .../src/editor/nodes/node-toolbar.tsx | 10 +- .../src/editor/nodes/nodes.style.tsx | 24 +- .../src/editor/nodes/role.tsx | 111 ++++-- .../src/editor/nodes/start.tsx | 10 +- .../src/editor/panel/add-node.tsx | 22 +- .../src/editor/panel/edit-node.tsx | 23 +- .../src/editor/panel/index.tsx | 10 +- .../src/editor/panel/toolbar.tsx | 63 +-- .../src/editor/trans/index.ts | 8 +- .../src/editor/trans/trans-in.ts | 62 +-- .../src/editor/trans/trans-out.ts | 25 +- .../src/editor/trans/type.ts | 2 +- .../src/editor/trans/validate.ts | 62 ++- .../workflow-dashboard/src/editor/type.ts | 6 +- .../src/editor/utils/eventer.ts | 8 +- .../src/editor/utils/index.ts | 2 - .../src/editor/utils/use-click-out.tsx | 14 +- packages/workflow-dashboard/src/index.css | 2 +- packages/workflow-dashboard/src/lib/utils.ts | 6 +- .../workflow-dashboard/src/pages/detail.tsx | 32 +- .../workflow-dashboard/src/pages/editor.tsx | 2 +- .../workflow-dashboard/src/pages/home.tsx | 10 +- packages/workflow-dashboard/src/router.tsx | 2 +- packages/workflow-dashboard/vite-dev.ts | 2 +- .../__tests__/process-logger.test.ts | 2 +- packages/workflow-util/src/index.ts | 2 +- 63 files changed, 1068 insertions(+), 612 deletions(-) create mode 100644 packages/cli-workflow/src/__tests__/workflow-resolution.test.ts diff --git a/packages/cli-workflow/src/__tests__/log.test.ts b/packages/cli-workflow/src/__tests__/log.test.ts index 75c839c..48dbbe5 100644 --- a/packages/cli-workflow/src/__tests__/log.test.ts +++ b/packages/cli-workflow/src/__tests__/log.test.ts @@ -62,9 +62,9 @@ const olderEntry = JSON.stringify({ async function writeLogFiles(): Promise { const logsDir = join(storageRoot, "logs"); - await writeFile(join(logsDir, "2026-05-20.jsonl"), [entry1, entry2, entry3].join("\n") + "\n"); - await writeFile(join(logsDir, "2026-05-19.jsonl"), oldEntry + "\n"); - await writeFile(join(logsDir, "2026-05-18.jsonl"), olderEntry + "\n"); + await writeFile(join(logsDir, "2026-05-20.jsonl"), `${[entry1, entry2, entry3].join("\n")}\n`); + await writeFile(join(logsDir, "2026-05-19.jsonl"), `${oldEntry}\n`); + await writeFile(join(logsDir, "2026-05-18.jsonl"), `${olderEntry}\n`); } describe("cmdLogList", () => { 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/setup.ts b/packages/cli-workflow/src/commands/setup.ts index 1014a60..538c9ab 100644 --- a/packages/cli-workflow/src/commands/setup.ts +++ b/packages/cli-workflow/src/commands/setup.ts @@ -141,7 +141,7 @@ function apiKeyEnvName(providerName: string): string { * Discover uwf-* agent binaries in PATH. * Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]). */ -async function discoverAgents(): Promise { +async function _discoverAgents(): Promise { try { // Use which -a to find all uwf-* binaries in PATH const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], { @@ -186,12 +186,15 @@ async function discoverAgents(): Promise { } // Parse which output - each line is a path to a binary - const paths = text.trim().split("\n").filter((line) => line.length > 0); + const paths = text + .trim() + .split("\n") + .filter((line) => line.length > 0); const agents = new Set(); for (const path of paths) { const basename = path.split("/").pop(); - if (basename && basename.startsWith("uwf-") && basename !== "uwf") { + if (basename?.startsWith("uwf-") && basename !== "uwf") { agents.add(basename); } } 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) { diff --git a/packages/workflow-agent-builtin/__tests__/path.test.ts b/packages/workflow-agent-builtin/__tests__/path.test.ts index 063475c..47ff143 100644 --- a/packages/workflow-agent-builtin/__tests__/path.test.ts +++ b/packages/workflow-agent-builtin/__tests__/path.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { resolvePath } from "../src/tools/path.js"; import { resolve } from "node:path"; +import { resolvePath } from "../src/tools/path.js"; describe("resolvePath", () => { test("resolves relative paths against cwd", () => { diff --git a/packages/workflow-agent-builtin/src/tools/run-command.ts b/packages/workflow-agent-builtin/src/tools/run-command.ts index 2f34843..4cd041f 100644 --- a/packages/workflow-agent-builtin/src/tools/run-command.ts +++ b/packages/workflow-agent-builtin/src/tools/run-command.ts @@ -56,8 +56,7 @@ function runShell( export const runCommandTool: BuiltinTool = { name: "run_command", - description: - "Run a shell command. Output is truncated to 32KB.", + description: "Run a shell command. Output is truncated to 32KB.", parameters: { type: "object", required: ["command"], diff --git a/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts b/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts index 74af338..db67558 100644 --- a/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts +++ b/packages/workflow-agent-claude-code/__tests__/session-detail.test.ts @@ -73,9 +73,7 @@ describe("parseClaudeCodeStreamOutput", () => { type: "user", message: { role: "user", - content: [ - { type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }, - ], + content: [{ type: "tool_result", tool_use_id: "tool_1", content: "file1.ts\nfile2.ts" }], }, session_id: "sess-123", }), @@ -167,7 +165,12 @@ describe("storeClaudeCodeDetail", () => { durationMs: 15000, model: "claude-sonnet-4.5", stopReason: "end_turn", - usage: { inputTokens: 100, outputTokens: 50, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }, + usage: { + inputTokens: 100, + outputTokens: 50, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + }, turns: [ { index: 0, role: "assistant", content: "hello", toolCalls: null }, { index: 1, role: "tool_result", content: "world", toolCalls: null }, diff --git a/packages/workflow-agent-claude-code/src/claude-code.ts b/packages/workflow-agent-claude-code/src/claude-code.ts index a5af640..550a2e2 100644 --- a/packages/workflow-agent-claude-code/src/claude-code.ts +++ b/packages/workflow-agent-claude-code/src/claude-code.ts @@ -1,8 +1,5 @@ import { spawn } from "node:child_process"; import type { Store } from "@uncaged/json-cas"; - -import { createLogger } from "@uncaged/workflow-util"; - import { type AgentContext, type AgentRunResult, @@ -11,6 +8,7 @@ import { getCachedSessionId, setCachedSessionId, } from "@uncaged/workflow-agent-kit"; +import { createLogger } from "@uncaged/workflow-util"; import { parseClaudeCodeStreamOutput, storeClaudeCodeDetail } from "./session-detail.js"; @@ -149,7 +147,12 @@ async function runClaudeCode(ctx: AgentContext): Promise { } return result; } catch (err) { - log("5VKR8N3Q", "resume failed for session %s, falling back to fresh run: %s", cachedSessionId, err); + log( + "5VKR8N3Q", + "resume failed for session %s, falling back to fresh run: %s", + cachedSessionId, + err, + ); } } } diff --git a/packages/workflow-agent-hermes/src/acp-client.ts b/packages/workflow-agent-hermes/src/acp-client.ts index 400c7c6..beb6213 100644 --- a/packages/workflow-agent-hermes/src/acp-client.ts +++ b/packages/workflow-agent-hermes/src/acp-client.ts @@ -267,8 +267,7 @@ export class HermesAcpClient { case "tool_call": { const title = (update.title as string) ?? ""; const rawInput = update.rawInput; - const args = - rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : ""; + const args = rawInput !== undefined && rawInput !== null ? JSON.stringify(rawInput) : ""; const toolCallId = update.toolCallId as string; this.pendingTools.set(toolCallId, { name: title, args }); diff --git a/packages/workflow-agent-kit/src/index.ts b/packages/workflow-agent-kit/src/index.ts index 6d18e65..ffeaaa6 100644 --- a/packages/workflow-agent-kit/src/index.ts +++ b/packages/workflow-agent-kit/src/index.ts @@ -12,8 +12,8 @@ export { export type { FrontmatterFastPathResult } from "./frontmatter.js"; export { tryFrontmatterFastPath } from "./frontmatter.js"; export { createAgent } from "./run.js"; -export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; export { getCachedSessionId, setCachedSessionId } from "./session-cache.js"; +export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js"; export type { AgentContext, AgentContinueFn, diff --git a/packages/workflow-agent-kit/src/session-cache.ts b/packages/workflow-agent-kit/src/session-cache.ts index 238afc9..fd94de5 100644 --- a/packages/workflow-agent-kit/src/session-cache.ts +++ b/packages/workflow-agent-kit/src/session-cache.ts @@ -1,5 +1,5 @@ -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { randomBytes } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { ThreadId } from "@uncaged/workflow-protocol"; diff --git a/packages/workflow-dashboard/index.html b/packages/workflow-dashboard/index.html index 0948c36..e566099 100644 --- a/packages/workflow-dashboard/index.html +++ b/packages/workflow-dashboard/index.html @@ -6,8 +6,8 @@ Workflow UI