refactor(FN-7039): delete WORKFLOW_STEP_TEMPLATES + legacy workflow-step management surface

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-26 00:06:31 -07:00
parent f987470e6d
commit a7eb3c4dd8
15 changed files with 318 additions and 2407 deletions

View File

@@ -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<string, unknown> }[] }).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);

View File

@@ -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");

View File

@@ -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 ──────────────────────────────────────────────

View File

@@ -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);
});
});

View File

@@ -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 <url>\` — 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",
},
},
],

View File

@@ -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 <base>...HEAD\` (or \`git diff <base>\`). 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",
},
},
],

View File

@@ -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 {

View File

@@ -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<import("./types.js").WorkflowStep> {
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<Set<string>> {
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<string>,
): Promise<string[] | undefined> {
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

View File

@@ -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 <url>\` — 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 <base>...HEAD\` (or \`git diff <base>\`). 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 {

View File

@@ -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);

View File

@@ -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<WorkflowStep[]> {
const path = withProjectId("/workflow-steps", projectId);
return dedupe(path, () => api<WorkflowStep[]>(path));
}
/** Create a new workflow step */
export function createWorkflowStep(input: WorkflowStepInput, projectId?: string): Promise<WorkflowStep> {
return api<WorkflowStep>(withProjectId("/workflow-steps", projectId), {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update a workflow step */
export function updateWorkflowStep(id: string, updates: Partial<WorkflowStepInput>, projectId?: string): Promise<WorkflowStep> {
return api<WorkflowStep>(withProjectId(`/workflow-steps/${id}`, projectId), {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete a workflow step */
export function deleteWorkflowStep(id: string, projectId?: string): Promise<void> {
return api<void>(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<WorkflowStep[]> {
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<WorkflowStep> {
return api<WorkflowStep>(withProjectId(`/workflow-step-templates/${encodeURIComponent(templateId)}/create`, projectId), {
method: "POST",
});
}
// ── Scripts API ────────────────────────────────────────────────────────
/** Script entry returned from the API */

View File

@@ -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(<WorkflowNodeEditor isOpen onClose={() => {}} 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(<WorkflowNodeEditor isOpen onClose={() => {}} 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<string, unknown> }[] } }).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(<WorkflowNodeEditor isOpen onClose={() => {}} 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<string, unknown> }[] } }).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(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);

File diff suppressed because it is too large Load Diff

View File

@@ -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<void>;
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, string | null>) => 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<string, string | null>) =>
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<string, unknown> = {};
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;

View File

@@ -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",