From a7eb3c4dd84dbcead59d1faa61061d1188a6f102 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 26 Jun 2026 00:06:31 -0700 Subject: [PATCH] refactor(FN-7039): delete WORKFLOW_STEP_TEMPLATES + legacy workflow-step management surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U6: delete the built-in WORKFLOW_STEP_TEMPLATES catalog + its materializer (getBuiltInWorkflowTemplate/ensureWorkflowStepForTemplate/toBuiltInWorkflowStep); inline the browser-verification + code-review name/prompt/toolMode/gateMode into their optional-group IR builders (node bytes unchanged); simplify resolveEnabledWorkflowSteps to an identity-stable pass-through (no materialization, so the optionalGroupIdSet collision guard is no longer needed). Plugin-contributed step templates are kept as the editor palette. U5: remove the legacy /api/workflow-steps REST surface (GET/POST/PATCH/DELETE + /refine + /workflow-step-templates/:id/create), the dead client fns, and the Settings management UI; GET /api/workflow-step-templates now serves plugin templates only. The create-time optional-step toggles remain. Scope: the workflow_steps store CRUD + table are intentionally KEPT — still consumed by the engine (merger/recovery) and needed by U7's migration; their removal + the table drop land in U7. Plan U5 + U6. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../builtin-code-review-group.test.ts | 41 +- .../src/__tests__/frontend-ux-policy.test.ts | 15 +- .../__tests__/store-workflow-steps.test.ts | 106 +- .../workflow-step-templates-verdict.test.ts | 28 - .../src/builtin-browser-verification-group.ts | 79 +- .../core/src/builtin-code-review-group.ts | 72 +- packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 150 +- packages/core/src/types.ts | 296 +--- packages/core/src/workflow-optional-steps.ts | 2 +- packages/dashboard/app/api/legacy.ts | 59 +- .../__tests__/WorkflowNodeEditor.test.tsx | 36 +- .../src/__tests__/routes-agents.test.ts | 1348 +---------------- packages/dashboard/src/routes.ts | 447 +----- .../workflow-step-template-verdicts.test.ts | 44 +- 15 files changed, 318 insertions(+), 2407 deletions(-) delete mode 100644 packages/core/src/__tests__/workflow-step-templates-verdict.test.ts diff --git a/packages/core/src/__tests__/builtin-code-review-group.test.ts b/packages/core/src/__tests__/builtin-code-review-group.test.ts index 5265daa935..fa3ed5cc51 100644 --- a/packages/core/src/__tests__/builtin-code-review-group.test.ts +++ b/packages/core/src/__tests__/builtin-code-review-group.test.ts @@ -6,7 +6,6 @@ import { } from "../builtin-code-review-group.js"; import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "../builtin-stepwise-coding-workflow-ir.js"; -import { WORKFLOW_STEP_TEMPLATES } from "../types.js"; import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; import { resolveDefaultOnOptionalGroupIds, @@ -15,37 +14,33 @@ import { /* FNXC:CodeReviewStep 2026-06-25-15:00: -Coverage for the DEFAULT-ON but TOGGLEABLE "Code Review" pre-merge step: the catalog -template fields, the `optional-group` node (defaultOn:true) built from it, and its wiring -into the coding + stepwise built-ins as a default-on optional group. Code review is a -WORKFLOW prompt-gate (shared verdict machinery), not engine verification code. +Coverage for the DEFAULT-ON but TOGGLEABLE "Code Review" pre-merge step: the +`optional-group` node (defaultOn:true) and its wiring into the coding + stepwise +built-ins as a default-on optional group. Code review is a WORKFLOW prompt-gate (shared +verdict machinery), not engine verification code. + +FNXC:WorkflowStepTemplate 2026-06-25-00:00: +U6 deleted the `WORKFLOW_STEP_TEMPLATES` catalog. The former "code-review catalog +fields" assertions are gone; the inlined literal values (name/toolMode/gateMode/prompt +verdict convention) are now asserted directly on the built group node below, which is the +parity oracle. */ -describe("code-review WORKFLOW_STEP_TEMPLATE", () => { - const template = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === "code-review"); - - it("exists with the expected catalog fields", () => { - expect(template).toBeTruthy(); - expect(template!.name).toBe("Code Review"); - expect(template!.toolMode).toBe("readonly"); - // Advisory → non-blocking by default (like the existing review); operators can promote. - expect(template!.gateMode).toBe("advisory"); - expect(template!.phase).toBe("pre-merge"); - expect(template!.description.length).toBeGreaterThan(0); - }); - - it("ends with the shared trailing verdict convention and reads the diff", () => { - const prompt = template!.prompt; +describe("codeReviewOptionalGroupNode", () => { + it("carries the inlined catalog literals (name/toolMode/gateMode/prompt)", () => { + const node = codeReviewOptionalGroupNode("in-progress"); + expect(node.config?.name).toBe("Code Review"); + const inner = (node.config?.template as { nodes: { config?: Record }[] }).nodes[0]; + expect(inner.config?.toolMode).toBe("readonly"); + expect(inner.config?.gateMode).toBe("advisory"); + const prompt = String(inner.config?.prompt); expect(prompt).toMatch(/"verdict":"APPROVE\|APPROVE_WITH_NOTES\|REVISE"/); expect(prompt).not.toContain('"verdict":"PASS"'); expect(prompt).not.toContain('"verdict":"FAIL"'); - // Focused on the value tests miss + reads the diff against the base. expect(prompt).toMatch(/git diff/); expect(prompt).toMatch(/out of scope/i); }); -}); -describe("codeReviewOptionalGroupNode", () => { it("builds a DEFAULT-ON optional-group with the stable group id and distinct inner id", () => { const node = codeReviewOptionalGroupNode("in-progress"); expect(node.id).toBe(CODE_REVIEW_GROUP_ID); diff --git a/packages/core/src/__tests__/frontend-ux-policy.test.ts b/packages/core/src/__tests__/frontend-ux-policy.test.ts index 298bc96b00..22d41bcbc4 100644 --- a/packages/core/src/__tests__/frontend-ux-policy.test.ts +++ b/packages/core/src/__tests__/frontend-ux-policy.test.ts @@ -4,7 +4,6 @@ import { applyFrontendUxCriteria, matchesFrontendUxPath, } from "../frontend-ux-policy.js"; -import { WORKFLOW_STEP_TEMPLATES } from "../types.js"; const EXACT_FRONTEND_UX_CRITERIA = `## Frontend UX Criteria @@ -103,14 +102,12 @@ Implement dashboard UI. expect(injected.match(/## Frontend UX Criteria/g)).toHaveLength(1); }); - it("keeps checklist tokens aligned with the frontend UX design persona", () => { - const persona = WORKFLOW_STEP_TEMPLATES.find((template) => template.id === "frontend-ux-design"); - expect(persona?.name).toBe("Frontend UX Design"); - expect(persona?.prompt).toContain("design tokens"); - expect(persona?.prompt).toContain("Component Reuse"); - expect(persona?.prompt).toContain("Responsive Behavior"); - expect(persona?.prompt).toContain("Visual Hierarchy"); - + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: the `frontend-ux-design` + // WORKFLOW_STEP_TEMPLATES persona was deleted in U6 (the built-in catalog is gone; + // only browser-verification + code-review survive, inlined into their group builders). + // The criteria-section token assertions that did not depend on the deleted persona are + // kept below. + it("keeps checklist tokens aligned with the injected frontend UX criteria section", () => { expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Design tokens only"); expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Component reuse"); expect(FRONTEND_UX_CRITERIA_SECTION).toContain("Responsive scaffolding"); diff --git a/packages/core/src/__tests__/store-workflow-steps.test.ts b/packages/core/src/__tests__/store-workflow-steps.test.ts index 0a50a79f91..42b9d205c2 100644 --- a/packages/core/src/__tests__/store-workflow-steps.test.ts +++ b/packages/core/src/__tests__/store-workflow-steps.test.ts @@ -302,7 +302,7 @@ describe("TaskStore Workflow Steps", () => { expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:disabled-step"]); }); - it("should keep plugin workflow IDs unchanged while materializing built-in templates", async () => { + it("keeps plugin and former-built-in workflow ids unchanged (all pass through)", async () => { store.setPluginWorkflowStepTemplates([ { pluginId: "my-plugin", @@ -318,16 +318,19 @@ describe("TaskStore Workflow Steps", () => { }, ]); - // frontend-ux-design is a built-in WORKFLOW_STEP_TEMPLATE that is NOT a - // builtin:coding optional-group node, so it still materializes into a WS row. - // (browser-verification can no longer be used here — it is a builtin:coding - // optional-group id that now passes through untouched; see FN-7039 regression.) + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: U6 deleted the built-in catalog + + // template materializer, so resolveEnabledWorkflowSteps is a pure pass-through. Both + // the plugin id AND a former built-in template id (frontend-ux-design) are kept + // verbatim — nothing materializes into a WS row. const task = await store.createTask({ description: "Task with mixed workflow steps", enabledWorkflowSteps: ["plugin:my-plugin:my-step", "frontend-ux-design"], }); - expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:my-step", "WS-001"]); + expect(task.enabledWorkflowSteps).toEqual(["plugin:my-plugin:my-step", "frontend-ux-design"]); + + const steps = await store.listWorkflowSteps(); + expect(steps.filter((step) => step.templateId === "frontend-ux-design")).toHaveLength(0); }); it("should update a workflow step", async () => { @@ -491,22 +494,21 @@ describe("TaskStore Workflow Steps", () => { expect(task.enabledWorkflowSteps).toEqual([ws1.id, ws2.id]); }); - it("should materialize built-in workflow templates when creating a task", async () => { - // frontend-ux-design is a plain built-in template (not a builtin:coding - // optional-group), so it materializes into a WS row. + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: U6 deleted the built-in + // WORKFLOW_STEP_TEMPLATES catalog + the template materializer, so + // resolveEnabledWorkflowSteps is now a pure identity-stable pass-through. A former + // built-in template id (frontend-ux-design) no longer materializes into a WS row — it + // passes through verbatim, exactly like any other enable id. + it("passes a former built-in template id (frontend-ux-design) through untouched without materializing", async () => { const task = await store.createTask({ description: "Task with frontend ux design", enabledWorkflowSteps: ["frontend-ux-design"], }); - expect(task.enabledWorkflowSteps).toEqual(["WS-001"]); + expect(task.enabledWorkflowSteps).toEqual(["frontend-ux-design"]); - const step = await store.getWorkflowStep("WS-001"); - expect(step).toMatchObject({ - id: "WS-001", - templateId: "frontend-ux-design", - name: "Frontend UX Design", - }); + const steps = await store.listWorkflowSteps(); + expect(steps.filter((step) => step.templateId === "frontend-ux-design")).toHaveLength(0); }); /* @@ -542,7 +544,10 @@ describe("TaskStore Workflow Steps", () => { expect(task.enabledWorkflowSteps).toEqual(["code-review"]); }); - it("should reuse an existing materialized built-in workflow step", async () => { + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: with pass-through resolution, the same + // former-built-in id used across two tasks stays identical and creates no rows (the + // old "reuse the materialized row" semantics no longer apply — nothing is materialized). + it("keeps a former built-in template id identical across tasks without materializing any row", async () => { const first = await store.createTask({ description: "First frontend ux design task", enabledWorkflowSteps: ["frontend-ux-design"], @@ -552,28 +557,11 @@ describe("TaskStore Workflow Steps", () => { enabledWorkflowSteps: ["frontend-ux-design"], }); - expect(first.enabledWorkflowSteps).toEqual(["WS-001"]); - expect(second.enabledWorkflowSteps).toEqual(["WS-001"]); + expect(first.enabledWorkflowSteps).toEqual(["frontend-ux-design"]); + expect(second.enabledWorkflowSteps).toEqual(["frontend-ux-design"]); const steps = await store.listWorkflowSteps(); - expect(steps.filter((step) => step.templateId === "frontend-ux-design")).toHaveLength(1); - }); - - it("should materialize frontend-ux-design built-in template when creating a task", async () => { - const task = await store.createTask({ - description: "Task with frontend UX design review", - enabledWorkflowSteps: ["frontend-ux-design"], - }); - - expect(task.enabledWorkflowSteps).toEqual(["WS-001"]); - - const step = await store.getWorkflowStep("WS-001"); - expect(step).toMatchObject({ - id: "WS-001", - templateId: "frontend-ux-design", - name: "Frontend UX Design", - toolMode: "readonly", - }); + expect(steps.filter((step) => step.templateId === "frontend-ux-design")).toHaveLength(0); }); it("should not set enabledWorkflowSteps when empty array provided", async () => { @@ -833,17 +821,23 @@ describe("TaskStore Workflow Steps", () => { } }); - it("should update task workflow steps and materialize built-in templates", async () => { + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: U6 made resolveEnabledWorkflowSteps a + // pure pass-through on the update path too — a former built-in template id is kept + // verbatim and never materialized into a WS row. + it("passes a former built-in template id through updateTask untouched (no materialization)", async () => { const task = await store.createTask({ description: "Editable task" }); const updated = await store.updateTask(task.id, { enabledWorkflowSteps: ["frontend-ux-design"], }); - expect(updated.enabledWorkflowSteps).toEqual(["WS-001"]); + expect(updated.enabledWorkflowSteps).toEqual(["frontend-ux-design"]); const persisted = await store.getTask(task.id); - expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]); + expect(persisted.enabledWorkflowSteps).toEqual(["frontend-ux-design"]); + + const steps = await store.listWorkflowSteps(); + expect(steps.filter((step) => step.templateId === "frontend-ux-design")).toHaveLength(0); }); // FNXC:WorkflowOptionalGroup 2026-06-26-04:30: FN-7039 update-path surface — a @@ -865,30 +859,14 @@ describe("TaskStore Workflow Steps", () => { expect(steps.filter((step) => step.templateId === "browser-verification")).toHaveLength(0); }); - it("should resolve built-in workflow templates from getWorkflowStep", async () => { - const step = await store.getWorkflowStep("browser-verification"); - - expect(step).toMatchObject({ - id: "browser-verification", - templateId: "browser-verification", - name: "Browser Verification", - mode: "prompt", - phase: "pre-merge", - toolMode: "coding", - }); - }); - - it("should resolve frontend-ux-design built-in template from getWorkflowStep", async () => { - const step = await store.getWorkflowStep("frontend-ux-design"); - - expect(step).toMatchObject({ - id: "frontend-ux-design", - templateId: "frontend-ux-design", - name: "Frontend UX Design", - mode: "prompt", - phase: "pre-merge", - toolMode: "readonly", - }); + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: U6 deleted the built-in + // WORKFLOW_STEP_TEMPLATES catalog and the getWorkflowStep built-in-synthesis + // fallback. Built-in quality gates (browser-verification, code-review) are now graph + // optional-group nodes, not synthesized WorkflowStep rows — so getWorkflowStep returns + // undefined for a built-in id that has no stored row. + it("returns undefined for built-in optional-group ids (no longer synthesized)", async () => { + expect(await store.getWorkflowStep("browser-verification")).toBeUndefined(); + expect(await store.getWorkflowStep("frontend-ux-design")).toBeUndefined(); }); // ── Workflow Step Phase ────────────────────────────────────────────── diff --git a/packages/core/src/__tests__/workflow-step-templates-verdict.test.ts b/packages/core/src/__tests__/workflow-step-templates-verdict.test.ts deleted file mode 100644 index 152831a23e..0000000000 --- a/packages/core/src/__tests__/workflow-step-templates-verdict.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { WORKFLOW_STEP_TEMPLATES } from "../types"; - -const TARGET_IDS = [ - "documentation-review", - "qa-check", - "security-audit", - "performance-review", - "accessibility-check", - "browser-verification", - "code-review", - "frontend-ux-design", -] as const; - -describe("workflow step template verdict contracts", () => { - it.each(TARGET_IDS)("%s uses canonical structured verdict output", (id) => { - const template = WORKFLOW_STEP_TEMPLATES.find((entry) => entry.id === id); - expect(template).toBeTruthy(); - - const prompt = template!.prompt; - expect(prompt).toMatch(/"verdict":"APPROVE\|APPROVE_WITH_NOTES\|REVISE"/); - expect(prompt).not.toContain('"verdict":"PASS"'); - expect(prompt).not.toContain('"verdict":"FAIL"'); - expect(prompt).not.toContain("task_done("); - expect(prompt).not.toContain("task_log("); - expect(prompt).toMatch(/Diff Scope|out of scope/i); - }); -}); diff --git a/packages/core/src/builtin-browser-verification-group.ts b/packages/core/src/builtin-browser-verification-group.ts index 72cab875e2..76287a6169 100644 --- a/packages/core/src/builtin-browser-verification-group.ts +++ b/packages/core/src/builtin-browser-verification-group.ts @@ -1,5 +1,4 @@ import type { WorkflowIrNode } from "./workflow-ir-types.js"; -import { WORKFLOW_STEP_TEMPLATES } from "./types.js"; /* FNXC:WorkflowOptionalGroup 2026-06-21-15:10: @@ -18,45 +17,77 @@ keeping it identical to the prior `optionalSteps` templateId preserves any persi (`browser-verification-step`) because a template node id may not collide with the group/top-level node id (U1 validation). -The inner node mirrors the dashboard's `stepTemplateToNode` projection of the -canonical `browser-verification` WORKFLOW_STEP_TEMPLATE: a `prompt` node carrying the -template's prompt, `toolMode` (coding), and `gateMode` (advisory default). Sourcing -prompt/toolMode from the catalog keeps the built-in byte-identical to the template a -human would insert from the palette (KTD-5). +FNXC:WorkflowOptionalGroup 2026-06-25-00:00: +U6 deleted the built-in step-template catalog; the inner node's literal +name/description/prompt/toolMode/gateMode are now inlined here directly (byte-identical +to the former `browser-verification` catalog entry). These built-ins are the parity +oracle, so the produced node bytes must NOT change. Plugin-contributed templates still +use the `WorkflowStepTemplate` shape via the editor palette, but built-ins no longer +read from a shared array. */ -function resolveBrowserVerificationTemplate() { - const tpl = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === "browser-verification"); - if (!tpl) { - throw new Error("browser-verification WORKFLOW_STEP_TEMPLATE is missing"); - } - return tpl; -} - -const BROWSER_VERIFICATION_TEMPLATE = resolveBrowserVerificationTemplate(); - /** Stable per-task enable key + group node id (preserved from the prior templateId). */ export const BROWSER_VERIFICATION_GROUP_ID = "browser-verification"; /** Inner template node id — distinct from the group id (template-node-id collision rule, U1). */ export const BROWSER_VERIFICATION_STEP_NODE_ID = "browser-verification-step"; +/** Display name (inlined from the former `browser-verification` catalog template). */ +const BROWSER_VERIFICATION_NAME = "Browser Verification"; + +/** Short description (inlined from the former catalog template). */ +const BROWSER_VERIFICATION_DESCRIPTION = "Verify web application functionality using browser automation"; + +/** Agent prompt (inlined verbatim from the former catalog template — parity oracle). */ +const BROWSER_VERIFICATION_PROMPT = `You are a browser verification specialist. Verify web application functionality after task implementation using the agent-browser CLI tool. + +## Prerequisites +First, determine the URL to verify. Check the task PROMPT.md for any URLs mentioned, or look at the code changes to identify the local development server URL (typically http://localhost:3000, http://localhost:5173, http://localhost:8080, etc.). + +## Verification Commands +Use these agent-browser commands for verification: +- \`agent-browser open \` — Navigate to the page +- \`agent-browser snapshot -i\` — Get interactive elements with refs (@e1, @e2, etc.) +- \`agent-browser click @e1\` — Click an element +- \`agent-browser fill @e1 "text"\` — Fill an input field +- \`agent-browser get text @e1\` — Get element text content +- \`agent-browser screenshot\` — Capture screenshot to file +- \`agent-browser wait --load networkidle\` — Wait for page to fully load + +## Verification Checklist +1. Page loads without JavaScript errors or blank screens +2. Navigation between pages/sections works +3. Forms accept input and submit correctly +4. Interactive elements (buttons, links) respond to clicks +5. Error states are handled gracefully +6. Screenshots capture expected content + +## Output Requirements +- Fast-bail: if Diff Scope contains no browser-verification-relevant UI files, output {"verdict":"APPROVE","notes":"out of scope: browser verification"} immediately. +- APPROVE: verification succeeds. +- APPROVE_WITH_NOTES: verification succeeds with non-blocking advisory findings; include evidence references in notes. +- REVISE: verification failures or regressions require changes; include failing behavior and actionable file paths in notes. +- Screenshots/artifacts referenced in notes are evidence only; verdict must be conveyed by the final JSON line. +- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): +{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."} + +Note: Refs (@e1, @e2) are invalidated after page navigation. Re-snapshot after clicking links or form submissions.`; + /** * Build the `browser-verification` optional-group node placed on a workflow's * pre-merge path. `column` matches where the legacy `workflow-step` seam sat * (in-progress) so the editor renders the group in the implementation column. * * Mirrors `stepTemplateToNode(browser-verification)`: a single `prompt` node whose - * config carries the catalog prompt + `toolMode: "coding"` + `gateMode: "advisory"`. + * config carries the inlined prompt + `toolMode: "coding"` + `gateMode: "advisory"`. */ export function browserVerificationOptionalGroupNode(column: string): WorkflowIrNode { - const tpl = BROWSER_VERIFICATION_TEMPLATE; return { id: BROWSER_VERIFICATION_GROUP_ID, kind: "optional-group", column, config: { - name: tpl.name, + name: BROWSER_VERIFICATION_NAME, defaultOn: false, template: { nodes: [ @@ -64,11 +95,11 @@ export function browserVerificationOptionalGroupNode(column: string): WorkflowIr id: BROWSER_VERIFICATION_STEP_NODE_ID, kind: "prompt", config: { - name: tpl.name, - description: tpl.description, - prompt: tpl.prompt ?? "", - toolMode: tpl.toolMode === "coding" ? "coding" : "readonly", - gateMode: tpl.gateMode ?? "advisory", + name: BROWSER_VERIFICATION_NAME, + description: BROWSER_VERIFICATION_DESCRIPTION, + prompt: BROWSER_VERIFICATION_PROMPT, + toolMode: "coding", + gateMode: "advisory", }, }, ], diff --git a/packages/core/src/builtin-code-review-group.ts b/packages/core/src/builtin-code-review-group.ts index 9aac33174f..c099a0e629 100644 --- a/packages/core/src/builtin-code-review-group.ts +++ b/packages/core/src/builtin-code-review-group.ts @@ -1,5 +1,4 @@ import type { WorkflowIrNode } from "./workflow-ir-types.js"; -import { WORKFLOW_STEP_TEMPLATES } from "./types.js"; /* FNXC:CodeReviewStep 2026-06-25-15:00: @@ -16,29 +15,55 @@ key; the inner template node carries a DISTINCT id (`code-review-step`) because node id may not collide with the group/top-level node id (U1 validation). The inner node mirrors the dashboard's `stepTemplateToNode` projection of the canonical -`code-review` WORKFLOW_STEP_TEMPLATE: a `prompt` node carrying the template's prompt, -`toolMode` (readonly — review reads the diff, never mutates), and `gateMode` (advisory — -non-blocking, like the existing review; operators can promote to a gate). Sourcing -prompt/toolMode/gateMode from the catalog keeps the built-in byte-identical to the -template a human would insert from the palette (KTD-5). +`code-review` step: a `prompt` node carrying the prompt, `toolMode` (readonly — review +reads the diff, never mutates), and `gateMode` (advisory — non-blocking, like the +existing review; operators can promote to a gate). + +FNXC:CodeReviewStep 2026-06-25-00:00: +U6 deleted the built-in step-template catalog; the inner node's literal +name/description/prompt/toolMode/gateMode are now inlined here directly (byte-identical +to the former `code-review` catalog entry). These built-ins are the parity oracle, so +the produced node bytes must NOT change. */ -function resolveCodeReviewTemplate() { - const tpl = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === "code-review"); - if (!tpl) { - throw new Error("code-review WORKFLOW_STEP_TEMPLATE is missing"); - } - return tpl; -} - -const CODE_REVIEW_TEMPLATE = resolveCodeReviewTemplate(); - /** Stable per-task enable key + group node id. */ export const CODE_REVIEW_GROUP_ID = "code-review"; /** Inner template node id — distinct from the group id (template-node-id collision rule, U1). */ export const CODE_REVIEW_STEP_NODE_ID = "code-review-step"; +/** Display name (inlined from the former `code-review` catalog template). */ +const CODE_REVIEW_NAME = "Code Review"; + +/** Short description (inlined from the former catalog template). */ +const CODE_REVIEW_DESCRIPTION = + "Diff-review the task's changes for correctness bugs, regressions, and intent mismatches that tests miss"; + +/** Agent prompt (inlined verbatim from the former catalog template — parity oracle). */ +const CODE_REVIEW_PROMPT = `You are a senior code reviewer. Review the task's diff for the correctness value automated tests do NOT catch. + +## Step 1: Read the change +1. Read the full diff against the base branch: \`git diff ...HEAD\` (or \`git diff \`). Determine the base from the task context / merge target. +2. Read the changed files in full where the diff is non-trivial, so you see the surrounding code paths the change touches — not just the hunks. + +## Step 2: Review focus (the value tests miss) +1. **Correctness / logic bugs** — wrong conditions, inverted boolean/comparison logic, off-by-one, incorrect operator precedence, mishandled return values. +2. **Broken edge cases** — empty/undefined/null inputs, zero/duplicate/boundary values, concurrency and ordering assumptions. +3. **Intent vs implementation** — does the code actually do what the task/PROMPT.md describes? Flag silent scope drift or partial implementations. +4. **Regressions in touched code paths** — does the change break or weaken an existing behavior in the files it edits or their callers? +5. **Error handling** — swallowed errors, unhandled rejections/exceptions, missing validation at trust boundaries, misleading error messages. +6. **Contract / signature changes** — changed function/exported-type signatures, API request/response shapes, or serialization that breaks existing callers. + +Be specific: cite \`file:line\` for every finding and explain the concrete failure it causes. + +## Output Requirements +- Fast-bail: if the diff is trivial, generated, or out-of-scope for code review (e.g. pure docs/config/formatting with no logic), output {"verdict":"APPROVE","notes":"out of scope: code review"} immediately and stop. +- APPROVE: no correctness concerns; use empty or brief notes. +- APPROVE_WITH_NOTES: shippable, but include non-blocking advisories (with file:line) in notes. +- REVISE: a correctness bug, regression, or contract break requires changes; include file:line and the concrete failure plus remediation in notes. +- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): +{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`; + /** * Build the `code-review` optional-group node placed on a workflow's pre-merge path. * `defaultOn: true` makes it run by default while remaining togglable per task. `column` @@ -46,16 +71,15 @@ export const CODE_REVIEW_STEP_NODE_ID = "code-review-step"; * the group in the implementation column. * * Mirrors `stepTemplateToNode(code-review)`: a single `prompt` node whose config carries - * the catalog prompt + `toolMode: "readonly"` + `gateMode: "advisory"`. + * the inlined prompt + `toolMode: "readonly"` + `gateMode: "advisory"`. */ export function codeReviewOptionalGroupNode(column: string): WorkflowIrNode { - const tpl = CODE_REVIEW_TEMPLATE; return { id: CODE_REVIEW_GROUP_ID, kind: "optional-group", column, config: { - name: tpl.name, + name: CODE_REVIEW_NAME, // Default-ON: runs for every coding task by default, but operators can toggle it // off per task (remove `code-review` from enabledWorkflowSteps). defaultOn: true, @@ -65,11 +89,11 @@ export function codeReviewOptionalGroupNode(column: string): WorkflowIrNode { id: CODE_REVIEW_STEP_NODE_ID, kind: "prompt", config: { - name: tpl.name, - description: tpl.description, - prompt: tpl.prompt ?? "", - toolMode: tpl.toolMode === "coding" ? "coding" : "readonly", - gateMode: tpl.gateMode ?? "advisory", + name: CODE_REVIEW_NAME, + description: CODE_REVIEW_DESCRIPTION, + prompt: CODE_REVIEW_PROMPT, + toolMode: "readonly", + gateMode: "advisory", }, }, ], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4df0f26a9e..8d5fc89398 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,4 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY, assertNotWorkspaceTaskMerge, isWorkspaceTask, WorkspaceTaskMergeError } from "./types.js"; export { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index c2225e2e31..436c344238 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -6,7 +6,7 @@ import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs"; import { detectWorkspaceRepos, saveWorkspaceConfig, loadWorkspaceConfig } from "./git-repository.js"; import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, CommitAssociationDiffBackfillReport, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; -import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey, assertNotWorkspaceTaskMerge } from "./types.js"; +import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, validateDocumentKey, assertNotWorkspaceTaskMerge } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; import { MOVED_SETTINGS_KEYS, @@ -116,7 +116,7 @@ import type { WorkflowNodeLayout, } from "./workflow-definition-types.js"; import { compileWorkflowToSteps, isInterpreterDeferredWorkflowCompileError } from "./workflow-compiler.js"; -import { resolveDefaultOnOptionalGroupIds, resolveAllOptionalGroupIds } from "./workflow-optional-steps.js"; +import { resolveDefaultOnOptionalGroupIds } from "./workflow-optional-steps.js"; import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, @@ -4217,28 +4217,6 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return `${Date.now()}-${id}-${sanitized}`; } - private getBuiltInWorkflowTemplate(templateId: string): import("./types.js").WorkflowStepTemplate | undefined { - return WORKFLOW_STEP_TEMPLATES.find((template) => template.id === templateId); - } - - private toBuiltInWorkflowStep(template: import("./types.js").WorkflowStepTemplate): import("./types.js").WorkflowStep { - const now = new Date().toISOString(); - return { - id: template.id, - templateId: template.id, - name: template.name, - description: template.description, - mode: "prompt", - phase: "pre-merge", - gateMode: "advisory", - prompt: template.prompt, - toolMode: template.toolMode || "readonly", - enabled: true, - createdAt: now, - updatedAt: now, - }; - } - private toStoredWorkflowStep(row: { id: string; templateId: string | null; @@ -4317,59 +4295,22 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return normalized; } - private async ensureWorkflowStepForTemplate(templateId: string): Promise { - const template = this.getBuiltInWorkflowTemplate(templateId); - if (!template) { - throw new Error(`Workflow step template '${templateId}' not found`); - } - - const existing = await this.getWorkflowStep(templateId); - if (existing && existing.id !== templateId) { - return existing; - } - - const allSteps = await this.listWorkflowSteps(); - const byName = allSteps.find((step) => step.name.toLowerCase() === template.name.toLowerCase()); - if (byName) { - return byName; - } - - return this.createWorkflowStep({ - templateId: template.id, - name: template.name, - description: template.description, - mode: "prompt", - phase: "pre-merge", - prompt: template.prompt, - gateMode: "advisory", - toolMode: template.toolMode || "readonly", - enabled: true, - }); - } - /* - FNXC:WorkflowOptionalGroup 2026-06-21-16:30: - `optionalGroupIds` are the optional-group node ids of the task's workflow. They are executor toggle keys (matched by node id in `enabledWorkflowSteps`), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately collide with a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"); without this pass-through the colliding id is materialized into a step row whose id differs from the group node id, so the executor's `enabledWorkflowSteps.includes(node.id)` check fails and an enabled group is silently bypassed (P1 from code review). Editor-authored group ids never collide (they come from `newNodeId()`), so they already passed through; this guards the built-in collision. + FNXC:WorkflowOptionalGroup 2026-06-25-00:00: + U6 deleted the built-in step-template catalog and its template + materializer (`getBuiltInWorkflowTemplate`/`ensureWorkflowStepForTemplate`/ + `toBuiltInWorkflowStep`). `resolveEnabledWorkflowSteps` is now a pure pass-through: + enable ids are trimmed + de-duplicated but otherwise pass through UNCHANGED, keeping + them identity-stable (KTD-6). There is no longer any built-in template to materialize + into a `WS-xxx` row, so the prior `optionalGroupIdSet` collision guard (which kept + built-in group ids out of materialization) is no longer needed and was removed — a + group id like "browser-verification" now passes straight through, exactly matching the + optional-group node id the executor toggles on `enabledWorkflowSteps.includes(node.id)`. + Plugin (`plugin:`-prefixed) ids also pass through. Workflow-compiled step rows are still + materialized separately via `materializeWorkflowSteps` (unchanged). */ - /* - FNXC:WorkflowOptionalGroup 2026-06-26-04:30: - Resolution order MUST mirror the executor's workflow resolution for the namespaces to line up: explicit `workflowId` → project default → `builtin:coding`. An unselected task with NO project default still runs `builtin:coding` (its optional-group nodes are `browser-verification` + `code-review`), so the toggle-key set must resolve there too. The earlier `?? getDefaultWorkflowId()` with an empty-set bail-out left this group-id set EMPTY for that common case, so a toggled `browser-verification` (which collides with a `WORKFLOW_STEP_TEMPLATES` id) got materialized into a `WS-NNN` step row the executor never matches against `enabledWorkflowSteps.includes(node.id)` — the optional step silently never ran and never appeared in the unified step progress bar (FN-7039 repro). Falling back to `builtin:coding` keeps the stored id equal to the group node id, so the executor runs it and the UI renders it. - */ - /** Optional-group node ids for a workflow (its `enabledWorkflowSteps` toggle - * keys). Resolves explicit `workflowId` → project default → `builtin:coding`, - * matching the executor's unselected-task resolution; empty for missing/fragment - * workflows. Used to keep group ids out of the legacy step-template - * materialization in {@link resolveEnabledWorkflowSteps}. */ - private async optionalGroupIdSet(workflowId?: string | null): Promise> { - const wfId = workflowId ?? (await this.getDefaultWorkflowId()) ?? "builtin:coding"; - const def = await this.getWorkflowDefinition(wfId); - if (!def || def.kind === "fragment") return new Set(); - return new Set(resolveAllOptionalGroupIds(def.ir)); - } - private async resolveEnabledWorkflowSteps( stepIds?: string[], - optionalGroupIds?: Set, ): Promise { if (!stepIds?.length) return undefined; @@ -4379,26 +4320,11 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} for (const rawId of stepIds) { const stepId = rawId.trim(); if (!stepId) continue; - - if (stepId.startsWith("plugin:")) { - if (!seen.has(stepId)) { - seen.add(stepId); - resolved.push(stepId); - } - continue; - } - - // Optional-group toggle ids pass through raw — never materialized as legacy step rows. - const template = optionalGroupIds?.has(stepId) - ? undefined - : this.getBuiltInWorkflowTemplate(stepId); - const resolvedId = template - ? (await this.ensureWorkflowStepForTemplate(stepId)).id - : stepId; - - if (!seen.has(resolvedId)) { - seen.add(resolvedId); - resolved.push(resolvedId); + // Identity-stable pass-through: plugin ids, built-in optional-group ids, and any + // other enable id are kept verbatim so the executor's node-id toggle check matches. + if (!seen.has(stepId)) { + seen.add(stepId); + resolved.push(stepId); } } @@ -4531,10 +4457,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} // Determine enabledWorkflowSteps: explicit input takes precedence, otherwise auto-apply default-on steps let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length - ? await this.resolveEnabledWorkflowSteps( - input.enabledWorkflowSteps, - await this.optionalGroupIdSet(input.workflowId), - ) + ? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps) : undefined; // When a project default workflow is configured, new tasks inherit it @@ -4729,10 +4652,7 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} const title = input.title?.trim() || undefined; let resolvedWorkflowSteps: string[] | undefined = input.enabledWorkflowSteps?.length - ? await this.resolveEnabledWorkflowSteps( - input.enabledWorkflowSteps, - await this.optionalGroupIdSet(input.workflowId), - ) + ? await this.resolveEnabledWorkflowSteps(input.enabledWorkflowSteps) : undefined; let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined; @@ -8805,13 +8725,12 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} task.nextRecoveryAt = updates.nextRecoveryAt; } if (updates.enabledWorkflowSteps !== undefined) { - // Pass the task's own workflow optional-group ids through untouched so a - // toggled built-in group id (e.g. "browser-verification") is not remapped - // to a materialized step row the executor never matches (code-review P1). - const taskWorkflowId = this.getTaskWorkflowSelection(task.id)?.workflowId; + // Enable ids pass through untouched (identity-stable, KTD-6) so a toggled + // built-in group id (e.g. "browser-verification") matches the optional-group + // node id the executor checks. U6 removed the template materializer, so there + // is no longer any remapping to guard against. task.enabledWorkflowSteps = await this.resolveEnabledWorkflowSteps( updates.enabledWorkflowSteps, - await this.optionalGroupIdSet(taskWorkflowId), ); } if (updates.noCommitsExpected === null) { @@ -14742,10 +14661,25 @@ ${stepsSection}`; return this.applyLegacyWorkflowStepOverrides(this.toStoredWorkflowStep(byTemplate)); } - const template = this.getBuiltInWorkflowTemplate(id); - return template ? this.toBuiltInWorkflowStep(template) : undefined; + // U6: the built-in step-template catalog was deleted. Built-in quality + // gates (browser-verification, code-review) are now graph optional-group nodes, not + // `workflow_steps` rows, so there is no built-in template to synthesize a step from. + // An id with no stored row resolves to undefined (callers treat that as "no step"). + return undefined; } + /* + FNXC:WorkflowStepCRUD 2026-06-25-00:00: + U5 removed the `/api/workflow-steps` REST surface (GET/POST/PATCH/DELETE + refine), the + `/workflow-step-templates/:id/create` route, the dead dashboard client mutations, and + (already absent) the Settings management UI. The store-level `workflow_steps` CRUD is + INTENTIONALLY KEPT in full: the table is retained (its drop is U7), `createWorkflowStep` + drives the workflow-compilation materializer (`materializeWorkflowSteps`), + `getWorkflowStep` is consumed by the engine (merger post-merge steps + executor + recovery), `listWorkflowSteps` backs `readConfig`, and `update`/`deleteWorkflowStep` + round-trip the table for those execution/read paths and their store tests. Removing the + store methods belongs with U7's table drop, not the management-surface removal. + */ /** * Update a workflow step definition. * @throws Error if the workflow step is not found diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 40af737a30..8235c72254 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -874,7 +874,17 @@ export interface WorkflowRunStepInstance { updatedAt: string; } -/** A built-in workflow step template for one-click creation. */ +/* +FNXC:WorkflowStepTemplate 2026-06-25-00:00: +U6 deleted the built-in step-template catalog array (the former value export). The +`WorkflowStepTemplate` SHAPE is KEPT because plugin-contributed step templates still use +it (they feed the +workflow-editor optional-group palette via `getPluginWorkflowStepTemplates`). It is no +longer backed by any built-in catalog: the former built-in `browser-verification` / +`code-review` literals now live inlined in their optional-group node builders +(`builtin-browser-verification-group.ts` / `builtin-code-review-group.ts`). +*/ +/** A workflow step template shape used by plugin-contributed steps (palette entries). */ export interface WorkflowStepTemplate { /** Unique template identifier (e.g., "documentation-review") */ id: string; @@ -908,290 +918,6 @@ export interface WorkflowStepTemplate { enabled?: boolean; } -/** Built-in workflow step templates available for one-click creation. */ -export const WORKFLOW_STEP_TEMPLATES: WorkflowStepTemplate[] = [ - { - id: "documentation-review", - name: "Documentation Review", - description: "Verify all public APIs, functions, and complex logic have appropriate documentation", - category: "Quality", - icon: "file-text", - toolMode: "readonly", - prompt: `You are a documentation reviewer. Review the completed task and verify documentation quality. - -Review Criteria: -1. All new public functions, classes, and modules have JSDoc comments or equivalent documentation -2. Complex logic has inline comments explaining the "why" not just the "what" -3. README files are updated if the task changes user-facing behavior -4. CHANGELOG or release notes are considered for significant changes -5. Type definitions are documented for public APIs - -Files to Review: -- Review all files modified in the task worktree -- Focus on public API surface area -- Check test files for test documentation - -Output Requirements: -- Fast-bail: if Diff Scope contains no documentation-relevant files, output {"verdict":"APPROVE","notes":"out of scope: documentation"} immediately. -- APPROVE: documentation is adequate; use empty or brief notes. -- APPROVE_WITH_NOTES: documentation is adequate with advisory improvements; include concise suggestions in notes. -- REVISE: documentation is missing or incorrect; include actionable file paths/functions in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`, - }, - { - id: "qa-check", - name: "QA Check", - description: "Run lint, tests, and typecheck; verify they pass and check for obvious bugs", - category: "Quality", - icon: "check-circle", - toolMode: "coding", - prompt: `You are a QA tester. Verify the task implementation by running lint, tests, and typecheck, and checking for bugs. - -Quality Gate Execution (all three must pass): -1. Run the project's lint command (e.g. \`pnpm lint\`, \`npm run lint\`) -2. Run the project's test suite (e.g. \`pnpm test\`, \`npm test\`, or the configured test command) -3. Run the project's typecheck command if one exists (e.g. \`pnpm typecheck\`, \`tsc --noEmit\`) -4. Verify lint, tests, and typecheck all pass -5. If any gate fails, analyze whether failures are related to the task changes - -Code Review: -1. Review the changes for obvious bugs or edge cases -2. Check error handling is appropriate -3. Verify input validation is present where needed -4. Look for common issues: null pointer risks, off-by-one errors, race conditions - -Output Requirements: -- Fast-bail: if Diff Scope contains no QA-relevant files, output {"verdict":"APPROVE","notes":"out of scope: QA"} immediately. -- APPROVE: lint/tests/typecheck pass and no actionable bugs. -- APPROVE_WITH_NOTES: quality gates pass but include non-blocking advisories in notes. -- REVISE: any gate fails or actionable bugs are found; include failing commands and affected file paths in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`, - }, - { - id: "security-audit", - name: "Security Audit", - description: "Check for common security vulnerabilities and anti-patterns", - category: "Security", - icon: "shield", - toolMode: "readonly", - prompt: `You are a security auditor. Review the task changes for common security vulnerabilities. - -Security Checklist: -1. **Injection vulnerabilities** — Check for SQL injection, command injection, XSS via unsanitized user input -2. **Secrets and credentials** — Ensure no hardcoded passwords, API keys, tokens, or private keys -3. **Unsafe eval** — Check for eval(), new Function(), or similar dangerous patterns -4. **Path traversal** — Verify file path handling prevents directory traversal attacks -5. **Insecure deserialization** — Check for unsafe parsing of untrusted data -6. **Authentication/Authorization** — Verify access controls are properly implemented -7. **Dependency risks** — Note any new dependencies that might have known vulnerabilities - -Files to Review: -- All modified files in the task -- Configuration files that might contain secrets -- Areas handling user input or external data - -Output Requirements: -- Fast-bail: if Diff Scope contains no security-relevant files, output {"verdict":"APPROVE","notes":"out of scope: security"} immediately. -- APPROVE: no security issues found. -- APPROVE_WITH_NOTES: no blocking issues, but include advisory hardening opportunities in notes. -- REVISE: vulnerabilities require changes; include file paths, severity, and remediation guidance in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`, - }, - { - id: "performance-review", - name: "Performance Review", - description: "Check for performance anti-patterns and optimization opportunities", - category: "Quality", - icon: "zap", - toolMode: "readonly", - prompt: `You are a performance reviewer. Analyze the task changes for performance implications. - -Performance Checklist: -1. **Algorithmic complexity** — Check for O(n²) or worse patterns that could bottleneck -2. **N+1 queries** — Look for database queries in loops -3. **Memory leaks** — Check for unclosed resources, event listeners, or accumulating caches -4. **Unnecessary re-renders** — For UI code, check for inefficient React/Angular/Vue patterns -5. **Bundle size** — Note if large dependencies are added unnecessarily -6. **Async patterns** — Verify proper use of async/await, Promise.all for parallel work -7. **Caching opportunities** — Identify where caching could improve performance - -Files to Review: -- All modified files, focusing on hot paths and frequently executed code -- Database query files -- API endpoints and route handlers - -Output Requirements: -- Fast-bail: if Diff Scope contains no performance-relevant files, output {"verdict":"APPROVE","notes":"out of scope: performance"} immediately. -- APPROVE: performance impact is acceptable. -- APPROVE_WITH_NOTES: acceptable overall, but include optimization advisories in notes. -- REVISE: performance risks require changes; include actionable file paths and optimization guidance in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`, - }, - { - id: "accessibility-check", - name: "Accessibility Check", - description: "Verify UI changes meet accessibility standards (WCAG 2.1)", - category: "Quality", - icon: "eye", - toolMode: "readonly", - prompt: `You are an accessibility reviewer. Check UI changes for WCAG 2.1 compliance. - -Accessibility Checklist: -1. **Keyboard navigation** — Ensure all interactive elements are keyboard accessible -2. **ARIA labels** — Check that screen reader announcements are appropriate -3. **Color contrast** — Verify text meets minimum contrast ratios (4.5:1 for normal text) -4. **Focus indicators** — Ensure visible focus states for keyboard navigation -5. **Alt text** — Check that images have meaningful alternative text -6. **Form labels** — Verify all inputs have associated labels -7. **Semantic HTML** — Check that proper HTML elements are used (buttons not divs) - -Files to Review: -- Modified UI components -- CSS/styling changes -- New HTML templates or JSX - -Output Requirements: -- Fast-bail: if Diff Scope contains no accessibility-relevant UI files, output {"verdict":"APPROVE","notes":"out of scope: accessibility"} immediately. -- APPROVE: accessibility requirements are met. -- APPROVE_WITH_NOTES: compliant overall, with advisory improvements in notes. -- REVISE: accessibility issues require changes; include file paths, WCAG references, and remediation steps in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`, - }, - { - id: "browser-verification", - name: "Browser Verification", - description: "Verify web application functionality using browser automation", - category: "Quality", - icon: "globe", - toolMode: "coding", - prompt: `You are a browser verification specialist. Verify web application functionality after task implementation using the agent-browser CLI tool. - -## Prerequisites -First, determine the URL to verify. Check the task PROMPT.md for any URLs mentioned, or look at the code changes to identify the local development server URL (typically http://localhost:3000, http://localhost:5173, http://localhost:8080, etc.). - -## Verification Commands -Use these agent-browser commands for verification: -- \`agent-browser open \` — Navigate to the page -- \`agent-browser snapshot -i\` — Get interactive elements with refs (@e1, @e2, etc.) -- \`agent-browser click @e1\` — Click an element -- \`agent-browser fill @e1 "text"\` — Fill an input field -- \`agent-browser get text @e1\` — Get element text content -- \`agent-browser screenshot\` — Capture screenshot to file -- \`agent-browser wait --load networkidle\` — Wait for page to fully load - -## Verification Checklist -1. Page loads without JavaScript errors or blank screens -2. Navigation between pages/sections works -3. Forms accept input and submit correctly -4. Interactive elements (buttons, links) respond to clicks -5. Error states are handled gracefully -6. Screenshots capture expected content - -## Output Requirements -- Fast-bail: if Diff Scope contains no browser-verification-relevant UI files, output {"verdict":"APPROVE","notes":"out of scope: browser verification"} immediately. -- APPROVE: verification succeeds. -- APPROVE_WITH_NOTES: verification succeeds with non-blocking advisory findings; include evidence references in notes. -- REVISE: verification failures or regressions require changes; include failing behavior and actionable file paths in notes. -- Screenshots/artifacts referenced in notes are evidence only; verdict must be conveyed by the final JSON line. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."} - -Note: Refs (@e1, @e2) are invalidated after page navigation. Re-snapshot after clicking links or form submissions.`, - }, - { - /* - FNXC:CodeReviewStep 2026-06-25-12:00: - Built-in "Code Review" catalog template: a configurable pre-merge prompt-gate that - diff-reviews the task's changes for the correctness value automated tests miss - (logic bugs, broken edge cases, intent-vs-implementation drift, regressions in - touched paths, error handling, contract/signature breaks). This is the WORKFLOW-layer - code review — it reuses the shared prompt-gate verdict machinery, NOT engine - verification code. gateMode defaults to "advisory" (non-blocking) exactly like - browser-verification, so it is opt-in/non-blocking until an operator promotes it to a - blocking gate. phase "pre-merge" places it before merge. toolMode "readonly": review - reads the diff/files, it does not mutate the worktree. - */ - id: "code-review", - name: "Code Review", - description: "Diff-review the task's changes for correctness bugs, regressions, and intent mismatches that tests miss", - category: "Quality", - icon: "git-pull-request", - toolMode: "readonly", - gateMode: "advisory", - phase: "pre-merge", - prompt: `You are a senior code reviewer. Review the task's diff for the correctness value automated tests do NOT catch. - -## Step 1: Read the change -1. Read the full diff against the base branch: \`git diff ...HEAD\` (or \`git diff \`). Determine the base from the task context / merge target. -2. Read the changed files in full where the diff is non-trivial, so you see the surrounding code paths the change touches — not just the hunks. - -## Step 2: Review focus (the value tests miss) -1. **Correctness / logic bugs** — wrong conditions, inverted boolean/comparison logic, off-by-one, incorrect operator precedence, mishandled return values. -2. **Broken edge cases** — empty/undefined/null inputs, zero/duplicate/boundary values, concurrency and ordering assumptions. -3. **Intent vs implementation** — does the code actually do what the task/PROMPT.md describes? Flag silent scope drift or partial implementations. -4. **Regressions in touched code paths** — does the change break or weaken an existing behavior in the files it edits or their callers? -5. **Error handling** — swallowed errors, unhandled rejections/exceptions, missing validation at trust boundaries, misleading error messages. -6. **Contract / signature changes** — changed function/exported-type signatures, API request/response shapes, or serialization that breaks existing callers. - -Be specific: cite \`file:line\` for every finding and explain the concrete failure it causes. - -## Output Requirements -- Fast-bail: if the diff is trivial, generated, or out-of-scope for code review (e.g. pure docs/config/formatting with no logic), output {"verdict":"APPROVE","notes":"out of scope: code review"} immediately and stop. -- APPROVE: no correctness concerns; use empty or brief notes. -- APPROVE_WITH_NOTES: shippable, but include non-blocking advisories (with file:line) in notes. -- REVISE: a correctness bug, regression, or contract break requires changes; include file:line and the concrete failure plus remediation in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."}`, - }, - { - id: "frontend-ux-design", - name: "Frontend UX Design", - description: "Verify visual polish and consistency with existing UI patterns and design tokens", - category: "Quality", - icon: "layout-grid", - toolMode: "readonly", - prompt: `You are a UX design reviewer. Verify frontend changes maintain visual polish and consistency with existing UI patterns and design tokens. - -## Step 1: Scope Check (MANDATORY FIRST) - -The task harness provides a "Diff Scope" listing files this task actually changed. - -If the Diff Scope contains ZERO frontend/UI files (no .tsx/.jsx/.ts/.js component files, no .css/.scss/.sass/.styl, no .html/.vue/.svelte/.astro, no design-token/theme files), output ONLY: -{"verdict":"APPROVE","notes":"out of scope: frontend UX design"} -Then STOP. Do not browse the worktree. Do not read any files. - -If there ARE frontend/UI files in scope, proceed to Step 2. - -## Step 2: Design Review - -Restrict your review to ONLY the UI files in the diff scope. - -Check: -1. **Visual Hierarchy** — heading levels, content flow, information architecture -2. **Spacing and Typography** — consistent margins, padding, gaps, type scale -3. **Color and Token Consistency** — CSS custom properties and design tokens used; no hardcoded colors -4. **Component Reuse** — existing components reused; no one-off styling or duplication -5. **Responsive Behavior** — layouts adapt across viewports -6. **Fit with Design Language** — border radius, shadows, transitions, icon style match patterns - -## Output Format - -- APPROVE: visual quality is acceptable; use empty or brief notes. -- APPROVE_WITH_NOTES: acceptable with non-blocking polish advisories; include specific notes. -- REVISE: issues require code changes; include specific files and required changes in notes. -- Final output: output exactly one trailing JSON object on the final line (no markdown fences, no surrounding prose): -{"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE","notes":"..."} - -Prioritize: layout breaks > visual inconsistency > style preferences. -Do NOT spend time on nits when no real issues exist.`, - }, -]; - export type PrConflictState = "clean" | "conflicting" | "behind" | "blocked" | "unknown"; export interface PrConflictDiagnostics { diff --git a/packages/core/src/workflow-optional-steps.ts b/packages/core/src/workflow-optional-steps.ts index f3fea182cd..85639c5fb4 100644 --- a/packages/core/src/workflow-optional-steps.ts +++ b/packages/core/src/workflow-optional-steps.ts @@ -80,7 +80,7 @@ export function resolveDefaultOnOptionalGroupIds(ir: WorkflowIr): string[] { /* FNXC:WorkflowOptionalGroup 2026-06-21-16:30: -Every optional-group node id in a workflow, regardless of `defaultOn`. These ids are executor toggle keys (the per-task `enabledWorkflowSteps` set), NOT legacy `WorkflowStep` template ids. A built-in group id can deliberately equal a `WORKFLOW_STEP_TEMPLATES` id (e.g. "browser-verification"), so the store must pass these through `resolveEnabledWorkflowSteps` untouched instead of materializing them into a step row whose id the executor would never match. +Every optional-group node id in a workflow, regardless of `defaultOn`. These ids are executor toggle keys (the per-task `enabledWorkflowSteps` set), NOT legacy `WorkflowStep` template ids. A built-in group id (e.g. "browser-verification") is passed through `resolveEnabledWorkflowSteps` untouched. (Historically it could collide with an id in the now-deleted built-in step-template catalog, which would wrongly materialize it into a step row the executor never matched; U6 removed that catalog + the materializer, so resolution is a pure identity-stable pass-through.) */ export function resolveAllOptionalGroupIds(ir: WorkflowIr): string[] { return resolveWorkflowOptionalSteps(ir).map((step) => step.templateId); diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 0c98e0ef6d..7d6c476b5f 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -5215,38 +5215,22 @@ export function clearActivityLog(projectId?: string): Promise<{ success: boolean // ── Workflow Steps ───────────────────────────────────────────────────── -/** Fetch all workflow step definitions */ -export function fetchWorkflowSteps(projectId?: string): Promise { - const path = withProjectId("/workflow-steps", projectId); - return dedupe(path, () => api(path)); -} - -/** Create a new workflow step */ -export function createWorkflowStep(input: WorkflowStepInput, projectId?: string): Promise { - return api(withProjectId("/workflow-steps", projectId), { - method: "POST", - body: JSON.stringify(input), - }); -} - -/** Update a workflow step */ -export function updateWorkflowStep(id: string, updates: Partial, projectId?: string): Promise { - return api(withProjectId(`/workflow-steps/${id}`, projectId), { - method: "PATCH", - body: JSON.stringify(updates), - }); -} - -/** Delete a workflow step */ -export function deleteWorkflowStep(id: string, projectId?: string): Promise { - return api(withProjectId(`/workflow-steps/${id}`, projectId), { method: "DELETE" }); -} - -/** Refine a workflow step's prompt using AI */ -export function refineWorkflowStepPrompt(id: string, projectId?: string): Promise<{ prompt: string; workflowStep: WorkflowStep }> { - return api<{ prompt: string; workflowStep: WorkflowStep }>(withProjectId(`/workflow-steps/${id}/refine`, projectId), { - method: "POST", - }); +/* +FNXC:WorkflowStepCRUD 2026-06-25-00:00: +U5 removed the legacy `/workflow-steps` CRUD/REST surface (GET list, POST create, +PATCH update, DELETE, refine) along with its Settings management UI. The client +mutation helpers (`createWorkflowStep`/`updateWorkflowStep`/`deleteWorkflowStep`/ +`refineWorkflowStepPrompt`/`createWorkflowStepFromTemplate`) had no remaining callers +and were deleted. `fetchWorkflowSteps` is retained as a stable, no-network shim +returning `[]`: its only remaining consumers are the plugin dashboard context's +`workflowSteps` field and the WorkflowResultsTab option list, both of which now source +step state from the graph (optional-group nodes) — the legacy definition list no longer +exists. Removing the field outright is graph-native U3 plumbing work, out of scope here. +*/ +/** Legacy workflow-step definition list (removed in U5). Resolves to an empty list: + * built-in/custom step definitions are now graph optional-group nodes, not DB rows. */ +export function fetchWorkflowSteps(_projectId?: string): Promise { + return Promise.resolve([]); } /** Fetch workflow step results for a task */ @@ -5594,7 +5578,9 @@ export function setProjectDefaultWorkflow( /** Re-export WorkflowStepTemplate type from core */ export type { WorkflowStepTemplate } from "@fusion/core"; -/** Fetch all built-in workflow step templates */ +/** Fetch the workflow step templates that feed the editor palette. The built-in + * built-in step-template catalog was deleted in U6, so this now returns only + * plugin-contributed templates. */ export function fetchWorkflowStepTemplates(): Promise<{ templates: import("@fusion/core").WorkflowStepTemplate[] }> { return api<{ templates: import("@fusion/core").WorkflowStepTemplate[] }>("/workflow-step-templates"); } @@ -5608,13 +5594,6 @@ export function fetchPluginWorkflowStepTemplates(): Promise<{ }>("/plugin-workflow-step-templates"); } -/** Create a workflow step from a built-in or plugin template */ -export function createWorkflowStepFromTemplate(templateId: string, projectId?: string): Promise { - return api(withProjectId(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, projectId), { - method: "POST", - }); -} - // ── Scripts API ──────────────────────────────────────────────────────── /** Script entry returned from the API */ diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 58ccbf5688..250412ab1a 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -2,7 +2,23 @@ import { readFileSync } from "node:fs"; import { useState } from "react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor, cleanup, within } from "@testing-library/react"; -import { parseWorkflowIr, WORKFLOW_STEP_TEMPLATES, type WorkflowDefinition, type Settings } from "@fusion/core"; +import { parseWorkflowIr, type WorkflowDefinition, type Settings } from "@fusion/core"; + +// FNXC:WorkflowStepTemplate 2026-06-25-00:00: U6 deleted the built-in +// WORKFLOW_STEP_TEMPLATES catalog. These palette tests only need an arbitrary set of +// step templates returned by `fetchWorkflowStepTemplates` to verify palette rendering + +// insertion; this local fixture replaces the deleted catalog (the editor is +// template-agnostic — it renders whatever the API returns). `WorkflowStepTemplate` is +// imported below (type-only imports hoist). +const STEP_TEMPLATE_FIXTURES: WorkflowStepTemplate[] = [ + { id: "documentation-review", name: "Documentation Review", description: "doc review", prompt: "You review docs.", category: "Quality", toolMode: "readonly" }, + { id: "qa-check", name: "QA Check", description: "qa", prompt: "You run QA.", category: "Quality", toolMode: "coding" }, + { id: "security-audit", name: "Security Audit", description: "sec", prompt: "You audit security.", category: "Security", toolMode: "readonly" }, + { id: "performance-review", name: "Performance Review", description: "perf", prompt: "You review perf.", category: "Quality", toolMode: "readonly" }, + { id: "accessibility-check", name: "Accessibility Check", description: "a11y", prompt: "You check a11y.", category: "Quality", toolMode: "readonly" }, + { id: "browser-verification", name: "Browser Verification", description: "browser", prompt: "You verify in a browser.", category: "Quality", toolMode: "coding" }, + { id: "frontend-ux-design", name: "Frontend UX Design", description: "ux", prompt: "You review UX.", category: "Quality", toolMode: "readonly" }, +]; import type { Agent, BoardWorkflowDefinition } from "../../api"; import { irToFlow, @@ -391,8 +407,10 @@ describe("workflow-flow-mapping", () => { const { edges } = edgeRenderableAssertion(builtinDef()); const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure"); // FNXC:WorkflowOptionalGroup 2026-06-21-15:30: the coding built-in's pre-merge `workflow-step` seam was migrated to a `browser-verification` optional-group (U6), which now carries the failure->end edge in its place. + // FNXC:CodeReviewStep 2026-06-25-00:00: the default-on `code-review` optional-group is also on the pre-merge success path with its own failure->end edge (see builtin-code-review-group.test.ts), so it is an expected failure->end source too. This corrected a stale assertion that predated the code-review group's addition. expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual([ "browser-verification", + "code-review", "execute", "merge-attempt", "planning", @@ -3239,7 +3257,7 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { it("surfaces all seven built-in add-ons in the palette", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([def()]); vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ - templates: WORKFLOW_STEP_TEMPLATES, + templates: STEP_TEMPLATE_FIXTURES, }); render( {}} addToast={() => {}} />); @@ -3247,13 +3265,13 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { // Every add-on id is present as a primary "insert as node" button AND offers // the "as optional group" sibling variant. - for (const tpl of WORKFLOW_STEP_TEMPLATES) { + for (const tpl of STEP_TEMPLATE_FIXTURES) { expect(screen.getByTestId(`wf-tpl-step-${tpl.id}`)).toBeInTheDocument(); expect( screen.getByTestId(`wf-tpl-step-${tpl.id}-optional-group`), ).toBeInTheDocument(); } - expect(WORKFLOW_STEP_TEMPLATES).toHaveLength(7); + expect(STEP_TEMPLATE_FIXTURES).toHaveLength(7); }); it("inserts an add-on as a single node carrying its template config", async () => { @@ -3261,7 +3279,7 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) })); vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ - templates: WORKFLOW_STEP_TEMPLATES, + templates: STEP_TEMPLATE_FIXTURES, }); render( {}} addToast={() => {}} />); @@ -3279,7 +3297,7 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; const ir = (updates as { ir: { nodes: { kind: string; config?: Record }[] } }).ir; - const docTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "documentation-review")!; + const docTpl = STEP_TEMPLATE_FIXTURES.find((tpl) => tpl.id === "documentation-review")!; const inserted = ir.nodes.find((n) => n.config?.name === docTpl.name); expect(inserted).toBeTruthy(); expect(inserted!.kind).toBe(docTpl.mode === "script" ? "script" : "prompt"); @@ -3290,7 +3308,7 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) })); vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ - templates: WORKFLOW_STEP_TEMPLATES, + templates: STEP_TEMPLATE_FIXTURES, }); render( {}} addToast={() => {}} />); @@ -3308,7 +3326,7 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; const ir = (updates as { ir: { nodes: { kind: string; config?: Record }[] } }).ir; - const secTpl = WORKFLOW_STEP_TEMPLATES.find((tpl) => tpl.id === "security-audit")!; + const secTpl = STEP_TEMPLATE_FIXTURES.find((tpl) => tpl.id === "security-audit")!; const group = ir.nodes.find((n) => n.kind === "optional-group"); expect(group).toBeTruthy(); expect(group!.config!.defaultOn).toBe(secTpl.defaultOn ?? false); @@ -3322,7 +3340,7 @@ describe("WorkflowNodeEditor — U9 palette Templates section", () => { vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ ...def(), ...(updates as object) })); vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); vi.mocked(fetchWorkflowStepTemplates).mockResolvedValue({ - templates: WORKFLOW_STEP_TEMPLATES, + templates: STEP_TEMPLATE_FIXTURES, }); render( {}} addToast={() => {}} />); diff --git a/packages/dashboard/src/__tests__/routes-agents.test.ts b/packages/dashboard/src/__tests__/routes-agents.test.ts index 318a300eef..14dbd8e615 100644 --- a/packages/dashboard/src/__tests__/routes-agents.test.ts +++ b/packages/dashboard/src/__tests__/routes-agents.test.ts @@ -311,1147 +311,6 @@ afterEach(() => { }); -describe("GET /workflow-steps", () => { - let store: TaskStore; - let app: express.Express; - - beforeAll(() => { - store = createMockStore(); - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(store)); - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns empty array when no workflow steps exist", async () => { - const res = await GET(app, "/api/workflow-steps"); - expect(res.status).toBe(200); - expect(res.body).toEqual([]); - }); - - it("returns workflow steps", async () => { - const steps = [ - { id: "WS-001", name: "Docs", description: "Check docs", prompt: "Review docs", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }, - ]; - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce(steps); - - const res = await GET(app, "/api/workflow-steps"); - expect(res.status).toBe(200); - expect(res.body).toEqual(steps); - }); -}); - -describe("POST /workflow-steps", () => { - let store: TaskStore; - let app: express.Express; - - async function postWorkflowStep(body: Record) { - return REQUEST(app, "POST", "/api/workflow-steps", JSON.stringify(body), { "Content-Type": "application/json" }); - } - - beforeAll(() => { - store = createMockStore(); - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(store)); - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.each([ - { - name: "creates a workflow step", - body: { - name: "Docs", - description: "Check docs", - }, - mockSetup: () => { - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Docs", - description: "Check docs", - mode: "prompt", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(res.body.id).toBe("WS-001"); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - name: "Docs", - description: "Check docs", - mode: "prompt", - phase: undefined, - prompt: undefined, - scriptName: undefined, - enabled: undefined, - defaultOn: false, - }); - }, - }, - { - name: "creates a workflow step with model override", - body: { - name: "Security", - description: "Security audit", - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - }, - mockSetup: () => { - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-002", - name: "Security", - description: "Security audit", - prompt: "", - enabled: true, - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - name: "Security", - description: "Security audit", - mode: "prompt", - phase: undefined, - prompt: undefined, - scriptName: undefined, - enabled: undefined, - defaultOn: false, - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - }); - }, - }, - { - name: "creates a workflow step without model fields when both empty strings", - body: { - name: "Docs", - description: "Check docs", - modelProvider: "", - modelId: "", - }, - mockSetup: () => { - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Docs", - description: "Check docs", - mode: "prompt", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - name: "Docs", - description: "Check docs", - mode: "prompt", - phase: undefined, - prompt: undefined, - scriptName: undefined, - enabled: undefined, - defaultOn: false, - modelProvider: undefined, - modelId: undefined, - }); - }, - }, - { - name: "creates a script-mode workflow step with valid scriptName", - body: { - name: "Run Tests", - description: "Execute tests", - mode: "script", - scriptName: "test", - }, - mockSetup: () => { - (store.getSettings as ReturnType).mockResolvedValueOnce({ - scripts: { test: "pnpm test", lint: "pnpm lint" }, - }); - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Run Tests", - description: "Execute tests", - mode: "script", - scriptName: "test", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - name: "Run Tests", - description: "Execute tests", - mode: "script", - phase: undefined, - prompt: undefined, - scriptName: "test", - enabled: undefined, - defaultOn: false, - }); - }, - }, - { - name: "creates a workflow step with 'post-merge' phase", - body: { - name: "Post Merge", - description: "After merge", - phase: "post-merge", - }, - mockSetup: () => { - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Post Merge", - description: "After merge", - mode: "prompt", - phase: "post-merge", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - name: "Post Merge", - description: "After merge", - mode: "prompt", - phase: "post-merge", - prompt: undefined, - scriptName: undefined, - enabled: undefined, - defaultOn: false, - }); - }, - }, - { - name: "creates a workflow step with defaultOn true", - body: { - name: "Auto Step", - description: "Auto-enabled", - defaultOn: true, - }, - mockSetup: () => { - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-010", - name: "Auto Step", - description: "Auto-enabled", - mode: "prompt", - prompt: "", - enabled: true, - defaultOn: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - name: "Auto Step", - description: "Auto-enabled", - mode: "prompt", - phase: undefined, - prompt: undefined, - scriptName: undefined, - enabled: undefined, - defaultOn: true, - }); - }, - }, - { - name: "defaults defaultOn to false when not specified", - body: { - name: "Manual Step", - description: "Manual only", - }, - mockSetup: () => { - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-011", - name: "Manual Step", - description: "Manual only", - mode: "prompt", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith(expect.objectContaining({ defaultOn: false })); - }, - }, - ])("$name", async ({ body, mockSetup, assert }) => { - mockSetup(); - const res = await postWorkflowStep(body); - assert(res); - }); - - it.each([ - { - name: "returns 400 when name is missing", - body: { description: "Check docs" }, - errorSubstring: "name", - }, - { - name: "returns 400 when description is missing", - body: { name: "Docs" }, - errorSubstring: "description", - }, - { - name: "returns 400 when model provider is set without modelId", - body: { name: "Security", description: "Security audit", modelProvider: "anthropic" }, - errorSubstring: "must include both provider and modelId", - }, - { - name: "returns 400 when modelId is set without model provider", - body: { name: "Security", description: "Security audit", modelId: "claude-sonnet-4-5" }, - errorSubstring: "must include both provider and modelId", - }, - { - name: "returns 400 for script mode without scriptName", - body: { name: "Run Tests", description: "Execute tests", mode: "script" }, - errorSubstring: "scriptName is required", - }, - { - name: "returns 400 for invalid mode value", - body: { name: "Test", description: "Test", mode: "invalid" }, - errorSubstring: "mode must be", - }, - { - name: "returns 400 for invalid phase value", - body: { name: "Test", description: "Test", phase: "during-merge" }, - errorSubstring: "phase must be", - }, - { - name: "returns 400 when defaultOn is not a boolean", - body: { name: "Bad Step", description: "Bad defaultOn", defaultOn: "yes" }, - errorSubstring: "defaultOn", - }, - ])("$name", async ({ body, errorSubstring }) => { - const res = await postWorkflowStep(body); - - expect(res.status).toBe(400); - expect(res.body.error).toContain(errorSubstring); - }); - - it("returns 409 when name already exists", async () => { - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce([ - { id: "WS-001", name: "Docs", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }, - ]); - - const res = await postWorkflowStep({ - name: "Docs", - description: "Another docs step", - }); - - expect(res.status).toBe(409); - expect(res.body.error).toContain("already exists"); - }); - - it("returns 400 for script mode with scriptName not in project scripts", async () => { - (store.getSettings as ReturnType).mockResolvedValueOnce({ - scripts: { lint: "pnpm lint" }, - }); - - const res = await postWorkflowStep({ - name: "Run Tests", - description: "Execute tests", - mode: "script", - scriptName: "nonexistent", - }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("not found in project settings"); - }); -}); - -describe("PATCH /workflow-steps/:id", () => { - let store: TaskStore; - let app: express.Express; - - async function patchWorkflowStep(body: Record, id = "WS-001") { - return REQUEST(app, "PATCH", `/api/workflow-steps/${id}`, JSON.stringify(body), { "Content-Type": "application/json" }); - } - - beforeAll(() => { - store = createMockStore(); - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(store)); - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.each([ - { - name: "updates a workflow step", - body: { name: "Updated", enabled: false }, - mockSetup: () => { - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Updated", - description: "Updated desc", - prompt: "Updated prompt", - enabled: false, - createdAt: "2026-01-01", - updatedAt: "2026-01-02", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(200); - expect(res.body.name).toBe("Updated"); - }, - }, - { - name: "updates a workflow step with model override", - body: { modelProvider: "anthropic", modelId: "claude-sonnet-4-5" }, - mockSetup: () => { - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Security", - description: "Audit", - prompt: "", - enabled: true, - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - createdAt: "2026-01-01", - updatedAt: "2026-01-02", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(200); - expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ - modelProvider: "anthropic", - modelId: "claude-sonnet-4-5", - })); - }, - }, - { - name: "updates a workflow step phase", - body: { phase: "post-merge" }, - mockSetup: () => { - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Post Merge", - description: "After merge", - phase: "post-merge", - createdAt: "2026-01-01", - updatedAt: "2026-01-02", - }); - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Pre Merge", - description: "Before merge", - mode: "prompt", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(200); - expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ phase: "post-merge" })); - }, - }, - { - name: "updates defaultOn to true", - body: { defaultOn: true }, - mockSetup: () => { - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Docs", - description: "Check docs", - mode: "prompt", - prompt: "", - enabled: true, - defaultOn: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(200); - expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: true })); - }, - }, - { - name: "updates defaultOn to false", - body: { defaultOn: false }, - mockSetup: () => { - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Docs", - description: "Check docs", - mode: "prompt", - prompt: "", - enabled: true, - defaultOn: false, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - }, - assert: (res: { status: number; body: any }) => { - expect(res.status).toBe(200); - expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", expect.objectContaining({ defaultOn: false })); - }, - }, - ])("$name", async ({ body, mockSetup, assert }) => { - mockSetup(); - const res = await patchWorkflowStep(body); - assert(res); - }); - - it.each([ - { - name: "returns 400 when updating with only modelProvider", - body: { modelProvider: "anthropic" }, - errorSubstring: "must include both provider and modelId", - }, - { - name: "returns 400 when updating with only modelId", - body: { modelId: "claude-sonnet-4-5" }, - errorSubstring: "must include both provider and modelId", - }, - { - name: "returns 400 when defaultOn is not a boolean in PATCH", - body: { defaultOn: "yes" }, - errorSubstring: "defaultOn", - }, - ])("$name", async ({ body, errorSubstring }) => { - const res = await patchWorkflowStep(body); - - expect(res.status).toBe(400); - expect(res.body.error).toContain(errorSubstring); - }); - - it("returns 404 for non-existent step", async () => { - (store.updateWorkflowStep as ReturnType).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found")); - - const res = await patchWorkflowStep({ name: "Nope" }, "WS-999"); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("not found"); - }); - - it("returns 400 when updating scriptName to nonexistent on existing script-mode step", async () => { - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Run Tests", - description: "Test runner", - mode: "script", - scriptName: "test", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - (store.getSettings as ReturnType).mockResolvedValueOnce({ - scripts: { test: "pnpm test", lint: "pnpm lint" }, - }); - - const res = await patchWorkflowStep({ scriptName: "nonexistent" }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("not found in project settings"); - expect(store.updateWorkflowStep).not.toHaveBeenCalled(); - }); - - it("returns 400 when updating script-mode step without scriptName (resulting state)", async () => { - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", - name: "Run Tests", - description: "Test runner", - mode: "script", - scriptName: "", - prompt: "", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - - const res = await patchWorkflowStep({ name: "Updated Name" }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("scriptName is required when mode is 'script'"); - }); - - it("returns 400 for invalid phase value on update", async () => { - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", name: "Test", description: "Test", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01", - }); - - const res = await patchWorkflowStep({ phase: "during-merge" }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("phase must be"); - }); -}); - -describe("DELETE /workflow-steps/:id", () => { - let store: TaskStore; - let app: express.Express; - - async function deleteWorkflowStep(id: string) { - return REQUEST(app, "DELETE", `/api/workflow-steps/${id}`, undefined, {}); - } - - beforeAll(() => { - store = createMockStore(); - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(store)); - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("deletes a workflow step", async () => { - (store.deleteWorkflowStep as ReturnType).mockResolvedValueOnce(undefined); - - const res = await deleteWorkflowStep("WS-001"); - - expect(res.status).toBe(204); - }); - - it("returns 404 for non-existent step", async () => { - (store.deleteWorkflowStep as ReturnType).mockRejectedValueOnce(new Error("Workflow step 'WS-999' not found")); - - const res = await deleteWorkflowStep("WS-999"); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("not found"); - }); -}); - -describe("POST /workflow-steps/:id/refine", () => { - let store: TaskStore; - let app: express.Express; - - beforeAll(() => { - store = createMockStore(); - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(store)); - }); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - __setCreateFnAgentForRefine(undefined); - }); - - it("returns 404 when workflow step not found", async () => { - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(undefined); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-999/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("not found"); - }); - - it("returns 400 when workflow step has no description", async () => { - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", name: "Empty", description: " ", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01", - }); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("no description"); - }); - - it("returns 400 when workflow step is in script mode", async () => { - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-001", name: "Run Tests", description: "Execute test suite", mode: "script", scriptName: "test", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01", - }); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(400); - expect(res.body.error).toContain("Cannot refine prompt for script-mode"); - }); - - it("returns AI-refined prompt when engine is available", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - (store.getSettings as ReturnType).mockResolvedValueOnce({ - planningProvider: "anthropic", - planningModelId: "claude-sonnet-4-5", - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let onText: ((delta: string) => void) | undefined; - const session = { - on: vi.fn((event: string, cb: (delta: string) => void) => { - if (event === "text") { - onText = cb; - } - }), - prompt: vi.fn(async () => { - onText?.("Refined "); - onText?.("prompt from AI"); - }), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async () => ({ session })); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(res.body.prompt).toBe("Refined prompt from AI"); - expect(res.body.workflowStep.prompt).toBe("Refined prompt from AI"); - expect(createFnAgentMock).toHaveBeenCalledTimes(1); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining("Name: Docs")); - expect(session.dispose).toHaveBeenCalledTimes(1); - expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Refined prompt from AI" }); - }); - - it("falls back to description when AI is unavailable", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - (store.getSettings as ReturnType).mockResolvedValueOnce({}); - const updatedWs = { ...ws, prompt: "Check docs" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - __setCreateFnAgentForRefine(async () => { - throw new Error("AI unavailable"); - }); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(res.body.prompt).toBe("Check docs"); - expect(res.body.workflowStep.prompt).toBe("Check docs"); - expect(store.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Check docs" }); - }); - - it("uses custom prompt from promptOverrides when provided", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - const customPrompt = "CUSTOM WORKFLOW STEP REFINE PROMPT"; - (store.getSettings as ReturnType).mockResolvedValueOnce({ - promptOverrides: { - "workflow-step-refine": customPrompt, - }, - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedSystemPrompt: string | undefined; - const session = { - on: vi.fn((event: string, cb: (delta: string) => void) => { - if (event === "text") { - cb("Refined "); - cb("prompt from AI"); - } - }), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => { - capturedSystemPrompt = options.systemPrompt; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(createFnAgentMock).toHaveBeenCalledTimes(1); - // Verify the custom prompt was passed - expect(capturedSystemPrompt).toBe(customPrompt); - }); - - it("uses default prompt when promptOverrides does not contain workflow-step-refine", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - // Settings with other overrides but not workflow-step-refine - (store.getSettings as ReturnType).mockResolvedValueOnce({ - promptOverrides: { - "executor-welcome": "Some other prompt", - }, - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedSystemPrompt: string | undefined; - const session = { - on: vi.fn((event: string, cb: (delta: string) => void) => { - if (event === "text") { - cb("Refined "); - cb("prompt from AI"); - } - }), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => { - capturedSystemPrompt = options.systemPrompt; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(createFnAgentMock).toHaveBeenCalledTimes(1); - // Should use the default prompt (contains "You are an expert at creating") - expect(capturedSystemPrompt).toContain("You are an expert at creating"); - expect(capturedSystemPrompt).toContain("workflow steps"); - }); - - // ── Lane Precedence Regression Tests ──────────────────────────────────────── - // Tests for FN-1730: ensure model resolution follows the documented hierarchy: - // 1. Project settings planningProvider + planningModelId (project lane) - // 2. Global settings planningGlobalProvider + planningGlobalModelId (global lane) - // 3. Default settings defaultProvider + defaultModelId (default fallback) - - it("uses project planning lane when configured", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - (store.getSettings as ReturnType).mockResolvedValueOnce({ - planningProvider: "anthropic", - planningModelId: "claude-sonnet-4-5", - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {}; - const session = { - on: vi.fn(), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => { - capturedModel = options; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(createFnAgentMock).toHaveBeenCalledTimes(1); - expect(capturedModel.defaultProvider).toBe("anthropic"); - expect(capturedModel.defaultModelId).toBe("claude-sonnet-4-5"); - }); - - it("falls back to global planning lane when project lane unset", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - (store.getSettings as ReturnType).mockResolvedValueOnce({ - planningGlobalProvider: "openai", - planningGlobalModelId: "gpt-4o", - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {}; - const session = { - on: vi.fn(), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => { - capturedModel = options; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(createFnAgentMock).toHaveBeenCalledTimes(1); - expect(capturedModel.defaultProvider).toBe("openai"); - expect(capturedModel.defaultModelId).toBe("gpt-4o"); - }); - - it("falls back to default lane when all planning lanes unset", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - (store.getSettings as ReturnType).mockResolvedValueOnce({ - defaultProvider: "mistral", - defaultModelId: "mistral-large", - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {}; - const session = { - on: vi.fn(), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => { - capturedModel = options; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(createFnAgentMock).toHaveBeenCalledTimes(1); - expect(capturedModel.defaultProvider).toBe("mistral"); - expect(capturedModel.defaultModelId).toBe("mistral-large"); - }); - - it("falls back to the project default override before the global default lane", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - (store.getSettings as ReturnType).mockResolvedValueOnce({ - defaultProviderOverride: "openai", - defaultModelIdOverride: "gpt-4o", - defaultProvider: "mistral", - defaultModelId: "mistral-large", - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {}; - const session = { - on: vi.fn(), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => { - capturedModel = options; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - expect(capturedModel.defaultProvider).toBe("openai"); - expect(capturedModel.defaultModelId).toBe("gpt-4o"); - }); - - it("ignores partial project lane (provider only, no modelId)", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (store.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - // Partial project lane: provider only, no modelId - (store.getSettings as ReturnType).mockResolvedValueOnce({ - planningProvider: "anthropic", - // missing planningModelId - planningGlobalProvider: "openai", - planningGlobalModelId: "gpt-4o", - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (store.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedModel: { defaultProvider?: string; defaultModelId?: string } = {}; - const session = { - on: vi.fn(), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { defaultProvider?: string; defaultModelId?: string }) => { - capturedModel = options; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST(app, "POST", "/api/workflow-steps/WS-001/refine", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(200); - // Partial project lane should be ignored, falls through to global lane - expect(capturedModel.defaultProvider).toBe("openai"); - expect(capturedModel.defaultModelId).toBe("gpt-4o"); - }); -}); - -// ── Workflow Step Refine with Scoped Settings (projectId) ────────────────── - -describe("POST /workflow-steps/:id/refine with projectId scoping", () => { - const projectId = "proj-refine-scoped"; - - let defaultStore: TaskStore; - let scopedStore: TaskStore; - let app: express.Express; - - beforeAll(() => { - defaultStore = createMockStore(); - scopedStore = createMockStore(); - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(defaultStore)); - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore); - }); - - afterEach(() => { - vi.restoreAllMocks(); - __setCreateFnAgentForRefine(undefined); - }); - - it("uses scoped settings from project store when projectId is provided", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (scopedStore.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - - const customPrompt = "CUSTOM SCOPED WORKFLOW REFINE PROMPT"; - (scopedStore.getSettings as ReturnType).mockResolvedValueOnce({ - promptOverrides: { - "workflow-step-refine": customPrompt, - }, - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (scopedStore.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedSystemPrompt: string | undefined; - const session = { - on: vi.fn((event: string, cb: (delta: string) => void) => { - if (event === "text") { - cb("Refined "); - cb("prompt from AI"); - } - }), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => { - capturedSystemPrompt = options.systemPrompt; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST( - app, - "POST", - `/api/workflow-steps/WS-001/refine?projectId=${projectId}`, - JSON.stringify({}), - { "Content-Type": "application/json" } - ); - - expect(res.status).toBe(200); - expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); - expect(scopedStore.getWorkflowStep).toHaveBeenCalledWith("WS-001"); - expect(scopedStore.getSettings).toHaveBeenCalled(); - expect(scopedStore.updateWorkflowStep).toHaveBeenCalledWith("WS-001", { prompt: "Refined prompt from AI" }); - // Verify the custom prompt from scoped settings was used - expect(capturedSystemPrompt).toBe(customPrompt); - }); - - it("uses default prompt from scoped settings when no workflow-step-refine override", async () => { - const ws = { id: "WS-001", name: "Docs", description: "Check docs", mode: "prompt", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }; - (scopedStore.getWorkflowStep as ReturnType).mockResolvedValueOnce(ws); - - // Scoped settings with other overrides but not workflow-step-refine - (scopedStore.getSettings as ReturnType).mockResolvedValueOnce({ - promptOverrides: { - "executor-welcome": "Some other prompt", - }, - }); - - const updatedWs = { ...ws, prompt: "Refined prompt from AI" }; - (scopedStore.updateWorkflowStep as ReturnType).mockResolvedValueOnce(updatedWs); - - let capturedSystemPrompt: string | undefined; - const session = { - on: vi.fn((event: string, cb: (delta: string) => void) => { - if (event === "text") { - cb("Refined "); - cb("prompt from AI"); - } - }), - prompt: vi.fn(async () => {}), - dispose: vi.fn(), - }; - - const createFnAgentMock = vi.fn(async (options: { cwd: string; systemPrompt: string; tools: string }) => { - capturedSystemPrompt = options.systemPrompt; - return { session }; - }); - __setCreateFnAgentForRefine(createFnAgentMock); - - const res = await REQUEST( - app, - "POST", - `/api/workflow-steps/WS-001/refine?projectId=${projectId}`, - JSON.stringify({}), - { "Content-Type": "application/json" } - ); - - expect(res.status).toBe(200); - expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId); - // Should use the default prompt from scoped settings - expect(capturedSystemPrompt).toContain("You are an expert at creating"); - expect(capturedSystemPrompt).toContain("workflow steps"); - }); -}); - // ── Agent Generation Routes ──────────────────────────────────────────────── describe("POST /api/agents/generate/* diagnostics", () => { @@ -1680,36 +539,18 @@ describe("GET /workflow-step-templates", () => { delete pluginRunner.getPluginWorkflowStepTemplates; }); - it("returns built-in templates with required fields and expected IDs", async () => { + // FNXC:WorkflowStepTemplate 2026-06-25-00:00: U6 deleted the built-in + // WORKFLOW_STEP_TEMPLATES catalog. GET /workflow-step-templates now serves ONLY + // plugin-contributed templates — with no plugins it returns an empty list (no built-ins). + it("returns an empty template list when no plugins contribute templates", async () => { const res = await GET(app, "/api/workflow-step-templates"); expect(res.status).toBe(200); expect(Array.isArray(res.body.templates)).toBe(true); - expect(res.body.templates.length).toBeGreaterThanOrEqual(5); - - for (const template of res.body.templates) { - expect(template).toEqual(expect.objectContaining({ - id: expect.any(String), - name: expect.any(String), - description: expect.any(String), - category: expect.any(String), - prompt: expect.any(String), - })); - } - - const ids = res.body.templates.map((t: { id: string }) => t.id); - expect(ids).toEqual(expect.arrayContaining([ - "documentation-review", - "qa-check", - "security-audit", - "performance-review", - "accessibility-check", - "browser-verification", - "frontend-ux-design", - ])); + expect(res.body.templates).toEqual([]); }); - it("merges plugin templates into workflow-step-templates response", async () => { + it("returns plugin-contributed templates (and no built-ins)", async () => { pluginRunner.getPluginWorkflowStepTemplates = () => [ { pluginId: "my-plugin", @@ -1728,7 +569,11 @@ describe("GET /workflow-step-templates", () => { const res = await GET(app, "/api/workflow-step-templates"); expect(res.status).toBe(200); - expect(res.body.templates.some((t: { id: string }) => t.id === "plugin:my-plugin:my-step")).toBe(true); + const ids = res.body.templates.map((t: { id: string }) => t.id); + expect(ids).toEqual(["plugin:my-plugin:my-step"]); + // No deleted built-in catalog ids leak through. + expect(ids).not.toContain("documentation-review"); + expect(ids).not.toContain("browser-verification"); }); it("returns plugin-only templates endpoint", async () => { @@ -1756,177 +601,6 @@ describe("GET /workflow-step-templates", () => { }); }); -describe("POST /workflow-step-templates/:id/create", () => { - let store: TaskStore; - let app: express.Express; - let pluginRunner: { getPluginWorkflowStepTemplates?: () => Array<{ pluginId: string; template: { id: string; name: string; description: string; prompt: string; toolMode: "readonly" | "coding"; category: string; icon: string } }> }; - - beforeAll(() => { - store = createMockStore(); - pluginRunner = {}; - app = express(); - app.use(express.json()); - app.use("/api", createApiRoutes(store, { pluginRunner: pluginRunner as any })); - }); - - beforeEach(() => { - vi.clearAllMocks(); - delete pluginRunner.getPluginWorkflowStepTemplates; - }); - - it("creates workflow step from template", async () => { - const created = { - id: "WS-001", - name: "Documentation Review", - description: "Verify all public APIs, functions, and complex logic have appropriate documentation", - prompt: expect.stringContaining("documentation reviewer"), - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }; - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce([]); - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce(created); - - const res = await REQUEST(app, "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(201); - expect(res.body.id).toBe("WS-001"); - expect(res.body.name).toBe("Documentation Review"); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - templateId: "documentation-review", - name: "Documentation Review", - description: "Verify all public APIs, functions, and complex logic have appropriate documentation", - prompt: expect.stringContaining("documentation reviewer"), - toolMode: "readonly", - enabled: true, - }); - }); - - it("creates workflow step from qa-check template", async () => { - const created = { - id: "WS-002", - name: "QA Check", - description: "Run lint, tests, and typecheck; verify they pass and check for obvious bugs", - prompt: expect.stringContaining("QA tester"), - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }; - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce([]); - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce(created); - - const res = await REQUEST(app, "POST", "/api/workflow-step-templates/qa-check/create", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(201); - expect(res.body.name).toBe("QA Check"); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - templateId: "qa-check", - name: "QA Check", - description: "Run lint, tests, and typecheck; verify they pass and check for obvious bugs", - prompt: expect.stringContaining("QA tester"), - toolMode: "coding", - enabled: true, - }); - }); - - it("creates workflow step from frontend-ux-design template", async () => { - const created = { - id: "WS-003", - name: "Frontend UX Design", - description: "Verify visual polish and consistency with existing UI patterns and design tokens", - prompt: expect.stringContaining("UX design reviewer"), - toolMode: "readonly", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }; - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce([]); - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce(created); - - const res = await REQUEST(app, "POST", "/api/workflow-step-templates/frontend-ux-design/create", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(201); - expect(res.body.name).toBe("Frontend UX Design"); - expect(res.body.toolMode).toBe("readonly"); - expect(store.createWorkflowStep).toHaveBeenCalledWith({ - templateId: "frontend-ux-design", - name: "Frontend UX Design", - description: "Verify visual polish and consistency with existing UI patterns and design tokens", - prompt: expect.stringContaining("UX design reviewer"), - toolMode: "readonly", - enabled: true, - }); - }); - - it("creates workflow step from plugin template", async () => { - pluginRunner.getPluginWorkflowStepTemplates = () => [ - { - pluginId: "my-plugin", - template: { - id: "plugin:my-plugin:my-step", - name: "My Plugin Step", - description: "Plugin contributed step", - prompt: "Run plugin checks", - toolMode: "coding", - category: "Plugin", - icon: "puzzle", - }, - }, - ]; - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce([]); - (store.createWorkflowStep as ReturnType).mockResolvedValueOnce({ - id: "WS-999", - templateId: "plugin:my-plugin:my-step", - name: "My Plugin Step", - description: "Plugin contributed step", - prompt: "Run plugin checks", - toolMode: "coding", - enabled: true, - createdAt: "2026-01-01", - updatedAt: "2026-01-01", - }); - - const res = await REQUEST(app, "POST", "/api/workflow-step-templates/plugin:my-plugin:my-step/create", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(201); - expect(store.createWorkflowStep).toHaveBeenCalledWith(expect.objectContaining({ - templateId: "plugin:my-plugin:my-step", - name: "My Plugin Step", - })); - }); - - it("returns 404 for non-existent template", async () => { - const res = await REQUEST(app, "POST", "/api/workflow-step-templates/nonexistent/create", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("not found"); - }); - - it("returns 409 when workflow step with same name already exists", async () => { - const existingSteps = [ - { id: "WS-001", name: "Documentation Review", description: "Check docs", prompt: "", enabled: true, createdAt: "2026-01-01", updatedAt: "2026-01-01" }, - ]; - (store.listWorkflowSteps as ReturnType).mockResolvedValueOnce(existingSteps); - - const res = await REQUEST(app, "POST", "/api/workflow-step-templates/documentation-review/create", JSON.stringify({}), { - "Content-Type": "application/json", - }); - - expect(res.status).toBe(409); - expect(res.body.error).toContain("already exists"); - }); -}); - describe("Agent create/update routes", () => { let tempDir: string; let fusionDir: string; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 8cff6f4654..b72d400c35 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -13,7 +13,7 @@ import * as nodeFs from "node:fs"; import os from "node:os"; import v8 from "node:v8"; -import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, WorkflowStepTemplate } from "@fusion/core"; +import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -247,17 +247,6 @@ export interface AuthStorageLike { get?(providerId: string): { type?: string; key?: string; access?: string; refresh?: string; expires?: number; [key: string]: unknown } | null | undefined; } -/** - * Extended session interface for workflow step refinement. - * The AgentSession from @earendil-works/pi-coding-agent has on() and prompt() methods - * but the local AgentSession type is minimal. - */ -interface RefineAgentSession { - on(event: "text", listener: (delta: string) => void): void; - prompt(text: string): Promise; - dispose(): void; -} - const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, // 5MB @@ -383,43 +372,6 @@ export function __setCreateFnAgentForRefine(mock: typeof createFnAgentForRefine) createFnAgentForRefine = mock; } -// Default system prompt for workflow step refinement (fallback when overrides unavailable) - -let resolveWorkflowStepRefinePrompt: (key: string, overrides?: Record) => string = () => DEFAULT_WORKFLOW_STEP_REFINE_PROMPT; -let promptOverridesReady = false; - -async function initPromptOverrides() { - if (promptOverridesReady) return; - try { - const core = await import("@fusion/core"); - resolveWorkflowStepRefinePrompt = (key: string, overrides?: Record) => - core.resolvePrompt(key as keyof typeof core.PROMPT_KEY_CATALOG, overrides); - promptOverridesReady = true; - } catch { - resolveWorkflowStepRefinePrompt = () => DEFAULT_WORKFLOW_STEP_REFINE_PROMPT; - promptOverridesReady = true; - } -} - -// Initialize on module load -initPromptOverrides(); - -/** Default system prompt for workflow step refinement */ -const DEFAULT_WORKFLOW_STEP_REFINE_PROMPT = `You are an expert at creating detailed agent prompts for workflow steps. - -A workflow step is a quality gate that runs after a task is implemented but before it's marked complete. - -Given a rough description, create a detailed prompt that an AI agent can follow to execute this workflow step. - -The prompt should: -1. Define the purpose clearly -2. Specify what files/context to examine -3. List specific criteria to check -4. Describe what "success" looks like -5. Include guidance on handling common edge cases - -Output ONLY the prompt text (no markdown, no explanations).`; - function validateOptionalModelField(value: unknown, name: string): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== "string") { @@ -2886,396 +2838,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } }); - // ── Workflow Step Routes ────────────────────────────────────────────── + // ── Workflow Step Templates (palette) ──────────────────────────────── - /** - * GET /api/workflow-steps - * List all workflow step definitions. - * Returns: WorkflowStep[] - */ - router.get("/workflow-steps", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const steps = await scopedStore.listWorkflowSteps(); - res.json(steps); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * POST /api/workflow-steps - * Create a new workflow step. - * Body: { name: string, description: string, mode?: "prompt"|"script", prompt?: string, scriptName?: string, enabled?: boolean, modelProvider?: string, modelId?: string } - * Returns: WorkflowStep - */ - router.post("/workflow-steps", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const { name, description, mode, phase, prompt, gateMode, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body; - - if (!name || typeof name !== "string" || !name.trim()) { - throw badRequest("name is required"); - } - if (!description || typeof description !== "string" || !description.trim()) { - throw badRequest("description is required"); - } - - // Validate mode - const resolvedMode: "prompt" | "script" = mode || "prompt"; - if (resolvedMode !== "prompt" && resolvedMode !== "script") { - throw badRequest("mode must be 'prompt' or 'script'"); - } - - // Validate phase - if (phase !== undefined && phase !== "pre-merge" && phase !== "post-merge") { - throw badRequest("phase must be 'pre-merge' or 'post-merge'"); - } - - if (prompt !== undefined && typeof prompt !== "string") { - throw badRequest("prompt must be a string"); - } - if (gateMode !== undefined && gateMode !== "gate" && gateMode !== "advisory") { - throw badRequest("gateMode must be 'gate' or 'advisory'"); - } - if (toolMode !== undefined && toolMode !== "readonly" && toolMode !== "coding") { - throw badRequest("toolMode must be 'readonly' or 'coding'"); - } - if (scriptName !== undefined && typeof scriptName !== "string") { - throw badRequest("scriptName must be a string"); - } - if (enabled !== undefined && typeof enabled !== "boolean") { - throw badRequest("enabled must be a boolean"); - } - if (defaultOn !== undefined && typeof defaultOn !== "boolean") { - throw badRequest("defaultOn must be a boolean"); - } - - // Validate script mode: scriptName must reference a named script in settings - if (resolvedMode === "script") { - if (!scriptName?.trim()) { - throw badRequest("scriptName is required when mode is 'script'"); - } - const settings = await scopedStore.getSettings(); - const scripts = settings.scripts || {}; - if (!(scriptName.trim() in scripts)) { - throw badRequest(`Script '${scriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}`); - } - } - - // Validate model override pair (only relevant for prompt mode) - const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model"); - - // Check for name conflicts - const existing = await scopedStore.listWorkflowSteps(); - if (existing.some((ws) => ws.name.toLowerCase() === name.trim().toLowerCase())) { - throw conflict(`A workflow step named '${name.trim()}' already exists`); - } - - const step = await scopedStore.createWorkflowStep({ - name: name.trim(), - description: description.trim(), - mode: resolvedMode, - phase, - prompt: prompt?.trim(), - gateMode, - toolMode, - scriptName: scriptName?.trim(), - enabled, - defaultOn: defaultOn === true, - modelProvider: modelPair.provider, - modelId: modelPair.modelId, - }); - res.status(201).json(step); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - const status = typeof (err instanceof Error ? err.message : String(err)) === "string" && ((err instanceof Error ? err.message : String(err)).includes("must include both provider and modelId") || (err instanceof Error ? err.message : String(err)).includes("Script mode requires")) ? 400 : 500; - throw new ApiError(status, err instanceof Error ? err.message : String(err)); - } - }); - - /** - * PATCH /api/workflow-steps/:id - * Update a workflow step. - * Body: Partial<{ name, description, mode, prompt, scriptName, enabled, modelProvider, modelId }> - * Returns: WorkflowStep - */ - router.patch("/workflow-steps/:id", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const { name, description, mode, phase, prompt, gateMode, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body; - - const updates: Record = {}; - if (name !== undefined) { - if (typeof name !== "string" || !name.trim()) { - throw badRequest("name must be a non-empty string"); - } - updates.name = name.trim(); - } - if (description !== undefined) { - if (typeof description !== "string" || !description.trim()) { - throw badRequest("description must be a non-empty string"); - } - updates.description = description.trim(); - } - if (mode !== undefined) { - if (mode !== "prompt" && mode !== "script") { - throw badRequest("mode must be 'prompt' or 'script'"); - } - updates.mode = mode; - } - if (phase !== undefined) { - if (phase !== "pre-merge" && phase !== "post-merge") { - throw badRequest("phase must be 'pre-merge' or 'post-merge'"); - } - updates.phase = phase; - } - if (prompt !== undefined) { - if (typeof prompt !== "string") { - throw badRequest("prompt must be a string"); - } - updates.prompt = prompt; - } - if (gateMode !== undefined) { - if (gateMode !== "gate" && gateMode !== "advisory") { - throw badRequest("gateMode must be 'gate' or 'advisory'"); - } - updates.gateMode = gateMode; - } - if (toolMode !== undefined) { - if (toolMode !== "readonly" && toolMode !== "coding") { - throw badRequest("toolMode must be 'readonly' or 'coding'"); - } - updates.toolMode = toolMode; - } - if (scriptName !== undefined) { - if (typeof scriptName !== "string") { - throw badRequest("scriptName must be a string"); - } - updates.scriptName = scriptName; - } - if (enabled !== undefined) { - if (typeof enabled !== "boolean") { - throw badRequest("enabled must be a boolean"); - } - updates.enabled = enabled; - } - if (defaultOn !== undefined) { - if (typeof defaultOn !== "boolean") { - throw badRequest("defaultOn must be a boolean"); - } - updates.defaultOn = defaultOn; - } - - // Validate script-mode requirements against the resulting state (existing + updates) - // This catches cases where an existing script-mode step has its scriptName updated - // without the mode field being explicitly sent. - const existingStep = await scopedStore.getWorkflowStep(req.params.id); - const resultingMode: string | undefined = updates.mode !== undefined ? (updates.mode as string) : existingStep?.mode; - const resultingScriptName: string | undefined = updates.scriptName !== undefined ? (updates.scriptName as string) : existingStep?.scriptName; - - if (resultingMode === "script") { - if (!resultingScriptName?.trim()) { - throw badRequest("scriptName is required when mode is 'script'"); - } - const settings = await scopedStore.getSettings(); - const scripts = settings.scripts || {}; - if (!(resultingScriptName.trim() in scripts)) { - throw badRequest(`Script '${resultingScriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}`); - } - } - - // Validate and apply model override pair - if (modelProvider !== undefined || modelId !== undefined) { - const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model"); - updates.modelProvider = modelPair.provider; - updates.modelId = modelPair.modelId; - } - - const step = await scopedStore.updateWorkflowStep(req.params.id, updates); - res.json(step); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err instanceof Error ? err.message : String(err)).includes("not found")) { - throw notFound(err instanceof Error ? err.message : String(err)); - } else { - const status = typeof (err instanceof Error ? err.message : String(err)) === "string" && ((err instanceof Error ? err.message : String(err)).includes("must include both provider and modelId") || (err instanceof Error ? err.message : String(err)).includes("Script mode requires")) ? 400 : 500; - throw new ApiError(status, err instanceof Error ? err.message : String(err)); - } - } - }); - - /** - * DELETE /api/workflow-steps/:id - * Delete a workflow step. - * Returns: 204 No Content - */ - router.delete("/workflow-steps/:id", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - await scopedStore.deleteWorkflowStep(req.params.id); - res.status(204).send(); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - if ((err instanceof Error ? err.message : String(err)).includes("not found")) { - throw notFound(err instanceof Error ? err.message : String(err)); - } else { - rethrowAsApiError(err); - } - } - }); - - /** - * POST /api/workflow-steps/:id/refine - * Use AI to refine the workflow step's description into a detailed agent prompt. - * Only available for prompt-mode steps. - * Returns: { prompt: string, workflowStep: WorkflowStep } - */ - router.post("/workflow-steps/:id/refine", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const step = await scopedStore.getWorkflowStep(req.params.id); - if (!step) { - throw notFound(`Workflow step '${req.params.id}' not found`); - } - - if (step.mode === "script") { - throw badRequest("Cannot refine prompt for script-mode workflow steps"); - } - - if (!step.description?.trim()) { - throw badRequest("Workflow step has no description to refine"); - } - - // Use AI to refine the description into a detailed agent prompt - let refinedPrompt: string; - try { - const createFnAgent = createFnAgentForRefine; - - const settings = await scopedStore.getSettings(); - - // Resolve the system prompt using prompt overrides (with fallback to default) - const systemPrompt = resolveWorkflowStepRefinePrompt( - "workflow-step-refine", - settings.promptOverrides - ) || DEFAULT_WORKFLOW_STEP_REFINE_PROMPT; - - if (!createFnAgent) { - throw new Error("createFnAgent is not available"); - } - const planningModel = resolvePlanningSettingsModel(settings); - const { session } = await createFnAgent({ - cwd: scopedStore.getRootDir(), - systemPrompt, - tools: "readonly", - // Resolve planning model using canonical lane hierarchy: - // 1. Project planning lane - // 2. Global planning lane - // 3. Project default override - // 4. Global default - defaultProvider: planningModel.provider, - defaultModelId: planningModel.modelId, - defaultThinkingLevel: settings.defaultThinkingLevel, - }); - - const refineSession = session as unknown as RefineAgentSession; - let output = ""; - refineSession.on("text", (delta: string) => { - output += delta; - }); - - await refineSession.prompt( - `Refine this workflow step description into a detailed agent prompt:\n\nName: ${step.name}\nDescription: ${step.description}` - ); - refineSession.dispose(); - - refinedPrompt = output.trim(); - } catch { - // Fallback: return the description as-is if AI is unavailable - refinedPrompt = step.description; - } - - // Update the workflow step with the refined prompt - const updated = await scopedStore.updateWorkflowStep(step.id, { prompt: refinedPrompt }); - res.json({ prompt: refinedPrompt, workflowStep: updated }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - // ── Workflow Step Templates ─────────────────────────────────────────── + /* + FNXC:WorkflowStepCRUD 2026-06-25-00:00: + U5/U6 removed the legacy workflow-step management surface: the + GET/POST/PATCH/DELETE `/workflow-steps` CRUD routes, the `/workflow-steps/:id/refine` + route, and the `/workflow-step-templates/:id/create` route are gone (their Settings + manager UI and the built-in step-template catalog were deleted). Workflow + quality gates now live as graph optional-group nodes, authored in the workflow editor. + Only the plugin-contributed step-template palette survives below. + */ /** * GET /api/workflow-step-templates - * List all built-in workflow step templates. + * List the plugin-contributed workflow step templates that feed the workflow + * editor's optional-group palette. The built-in step-template catalog + * was deleted in U6, so only plugin templates remain. * Returns: { templates: WorkflowStepTemplate[] } */ - router.get("/workflow-step-templates", async (_req, res) => { + router.get("/workflow-step-templates", (_req, res) => { try { - const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core"); const pluginTemplates = options?.pluginRunner?.getPluginWorkflowStepTemplates?.() ?? []; - res.json({ - templates: [ - ...WORKFLOW_STEP_TEMPLATES, - ...pluginTemplates.map(({ template }) => template), - ], - }); - } catch (err: unknown) { - if (err instanceof ApiError) { - throw err; - } - rethrowAsApiError(err); - } - }); - - /** - * POST /api/workflow-step-templates/:id/create - * Create a workflow step from a built-in template. - * Returns: WorkflowStep - */ - router.post("/workflow-step-templates/:id/create", async (req, res) => { - try { - const { store: scopedStore } = await getProjectContext(req); - const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core"); - let template: WorkflowStepTemplate | undefined = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id); - - if (!template) { - const pluginTemplates = options?.pluginRunner?.getPluginWorkflowStepTemplates?.() ?? []; - template = pluginTemplates.find(({ template: pluginTemplate }) => pluginTemplate.id === req.params.id)?.template; - } - - if (!template) { - throw notFound(`Template '${req.params.id}' not found`); - } - - // Check for name conflicts with existing workflow steps - const existing = await scopedStore.listWorkflowSteps(); - if (existing.some((ws) => ws.name.toLowerCase() === template.name.toLowerCase())) { - throw conflict(`A workflow step named '${template.name}' already exists`); - } - - const step = await scopedStore.createWorkflowStep({ - templateId: template.id, - name: template.name, - description: template.description, - prompt: template.prompt, - toolMode: template.toolMode, - enabled: true, - }); - - res.status(201).json(step); + res.json({ templates: pluginTemplates.map(({ template }) => template) }); } catch (err: unknown) { if (err instanceof ApiError) { throw err; diff --git a/packages/engine/src/__tests__/workflow-step-template-verdicts.test.ts b/packages/engine/src/__tests__/workflow-step-template-verdicts.test.ts index 1edae6cac6..f41613a621 100644 --- a/packages/engine/src/__tests__/workflow-step-template-verdicts.test.ts +++ b/packages/engine/src/__tests__/workflow-step-template-verdicts.test.ts @@ -1,37 +1,35 @@ -import { WORKFLOW_STEP_TEMPLATES } from "@fusion/core"; import { describe, expect, it } from "vitest"; import { inferWorkflowStepVerdictFromProse, parseWorkflowStepVerdict } from "../executor.js"; -const TARGET_TEMPLATE_IDS = [ - "documentation-review", - "qa-check", - "security-audit", - "performance-review", - "accessibility-check", - "browser-verification", - "frontend-ux-design", -] as const; - -describe("workflow step template verdict interoperability", () => { - it.each(TARGET_TEMPLATE_IDS)("%s supports canonical JSON and prose fallback", (id) => { - const template = WORKFLOW_STEP_TEMPLATES.find((entry) => entry.id === id); - expect(template).toBeTruthy(); - - const promptBody = template!.prompt; - expect(promptBody).toContain('"verdict":"APPROVE|APPROVE_WITH_NOTES|REVISE"'); - +/* +FNXC:WorkflowStepResults 2026-06-26: the WORKFLOW_STEP_TEMPLATES catalog was deleted +(graph-native cutover, plan U6) — built-in quality gates now live as optional-group IR +nodes whose prompt envelopes are asserted by the core builtin-group tests. This suite +retains the executor-owned VERDICT PARSER coverage (`parseWorkflowStepVerdict` / +`inferWorkflowStepVerdictFromProse`), which the graph path still uses to interpret +prompt-mode workflow-step output, independent of any template catalog. +*/ +describe("workflow step verdict parsing", () => { + it("parses canonical structured verdicts", () => { expect(parseWorkflowStepVerdict('{"verdict":"APPROVE","notes":""}')).toEqual({ verdict: "APPROVE", notes: "", }); - expect(parseWorkflowStepVerdict(`{"verdict":"APPROVE","notes":"out of scope: ${id}"}`)).toEqual({ + expect(parseWorkflowStepVerdict('{"verdict":"APPROVE","notes":"out of scope"}')).toEqual({ verdict: "APPROVE", - notes: `out of scope: ${id}`, + notes: "out of scope", }); - expect(parseWorkflowStepVerdict(`{"verdict":"APPROVE_WITH_NOTES","notes":"advisory only: ${id}"}`)).toEqual({ + expect(parseWorkflowStepVerdict('{"verdict":"APPROVE_WITH_NOTES","notes":"advisory only"}')).toEqual({ verdict: "APPROVE_WITH_NOTES", - notes: `advisory only: ${id}`, + notes: "advisory only", }); + expect(parseWorkflowStepVerdict('{"verdict":"REVISE","notes":"fix auth"}')).toEqual({ + verdict: "REVISE", + notes: "fix auth", + }); + }); + + it("infers REVISE from the legacy prose fallback", () => { expect(inferWorkflowStepVerdictFromProse("REQUEST REVISION\nfix packages/foo.ts")).toEqual({ verdict: "REVISE", notes: "fix packages/foo.ts",