From 53fe0d71b9b7865b50d7377bc5770b6edf5e9df0 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 11:19:40 -0700 Subject: [PATCH] FN-7584: register builtin:brainstorming workflow with ask-user/exit-gate loop Adds a discoverable built-in Brainstorming workflow composing the ask-user + exit-gate reach-out loop ahead of the standard coding plan/execute/review/merge spine, plus a WorkflowNodeEditor fix so clearing the ask-user question textarea deletes the config key instead of persisting an empty string. - Add packages/core/src/builtin-brainstorming-workflow-ir.ts registering builtin:brainstorming (non-default, default-enabled): ask-user -> refine prompt -> exit-gate-on-approval ahead of the unmodified Coding plan/execute/review/merge spine - Wire the new builtin into packages/core/src/builtin-workflows.ts and extend the builtin-workflows parity test suite - Add builtin-brainstorming-workflow-ir.test.ts covering the new workflow's IR shape and validation - Fix WorkflowNodeEditor.tsx ask-user question textarea onChange to delete the config.question key when cleared to empty (validateAskUserAndExitGateNodes rejects present-but-empty question; only an absent key falls back to the engine default) - Update docs/workflow-steps.md to document builtin:brainstorming as a selectable built-in composition - Add .changeset/fn-7584-brainstorming-builtin.md (minor, feature) Files changed: .changeset/fn-7584-brainstorming-builtin.md | 7 ++ docs/workflow-steps.md | 2 +- .../builtin-brainstorming-workflow-ir.test.ts | 79 ++++++++++++++++ .../core/src/__tests__/builtin-workflows.test.ts | 66 +++++++++++++ .../core/src/builtin-brainstorming-workflow-ir.ts | 103 +++++++++++++++++++++ packages/core/src/builtin-workflows.ts | 46 +++++++++ .../app/components/WorkflowNodeEditor.tsx | 18 +++- 7 files changed, 319 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7584 Fusion-Task-Lineage: 2c0258c2-9a35-403a-8688-ee49393a7231 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7584-brainstorming-builtin.md | 7 ++ docs/workflow-steps.md | 2 +- .../builtin-brainstorming-workflow-ir.test.ts | 79 ++++++++++++++ .../src/__tests__/builtin-workflows.test.ts | 66 +++++++++++ .../src/builtin-brainstorming-workflow-ir.ts | 103 ++++++++++++++++++ packages/core/src/builtin-workflows.ts | 46 ++++++++ .../app/components/WorkflowNodeEditor.tsx | 18 ++- 7 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-7584-brainstorming-builtin.md create mode 100644 packages/core/src/__tests__/builtin-brainstorming-workflow-ir.test.ts create mode 100644 packages/core/src/builtin-brainstorming-workflow-ir.ts diff --git a/.changeset/fn-7584-brainstorming-builtin.md b/.changeset/fn-7584-brainstorming-builtin.md new file mode 100644 index 0000000000..21c73e7257 --- /dev/null +++ b/.changeset/fn-7584-brainstorming-builtin.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a built-in "Brainstorming" workflow that talks to you before planning. +category: feature +dev: Registers `builtin:brainstorming` (non-default, default-enabled) composing FN-7579's `ask-user` → refine → `exit-gate`-on-approval phase ahead of the normal coding plan/execute/review/merge spine. Parity suite (`builtin-workflows.test.ts`) extended for the new entry. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 3100eea654..9e453b6d98 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -382,7 +382,7 @@ start → ask (ask-user: "Anything to refine?") the PR review loop) ``` -Each turn, the user is asked to refine; once they reply "looks good" (or whatever the condition matches), the exit-gate routes the task out of the brainstorm loop. This is a documented composition, not a registered built-in workflow — copy the shape into a custom workflow's IR via `fn_workflow_create`/`fn_workflow_update`. +Each turn, the user is asked to refine; once they reply "looks good" (or whatever the condition matches), the exit-gate routes the task out of the brainstorm loop. This composition is also available as a discoverable built-in: `builtin:brainstorming` (FN-7584) registers exactly this shape — `ask-user` → a refine prompt step → `exit-gate`-on-approval — ahead of the unmodified default Coding plan/execute/review/merge spine, selectable directly from the workflow picker. Copy the shape into a custom workflow's IR via `fn_workflow_create`/`fn_workflow_update` when you need a different downstream pipeline than the standard coding one. #### Workflow-defined custom task fields diff --git a/packages/core/src/__tests__/builtin-brainstorming-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-brainstorming-workflow-ir.test.ts new file mode 100644 index 0000000000..6ca93de2c6 --- /dev/null +++ b/packages/core/src/__tests__/builtin-brainstorming-workflow-ir.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_BRAINSTORMING_WORKFLOW_IR } from "../builtin-brainstorming-workflow-ir.js"; +import { parseWorkflowIr, serializeWorkflowIr } from "../workflow-ir.js"; +import type { WorkflowIrV2 } from "../workflow-ir-types.js"; + +/* +FNXC:WorkflowBrainstorming 2026-07-05-00:00: +FN-7584 registers builtin:brainstorming as a discoverable built-in composing +FN-7579's ask-user -> refine -> exit-gate-on-approval phase ahead of the normal +coding plan/execute path. These tests pin the IR shape: it parses, orders the +brainstorm nodes before the plan/execute spine, and round-trips through +serialize/parse. +*/ + +describe("builtin brainstorming workflow IR", () => { + it("parses through parseWorkflowIr", () => { + expect(() => parseWorkflowIr(BUILTIN_BRAINSTORMING_WORKFLOW_IR)).not.toThrow(); + const ir = BUILTIN_BRAINSTORMING_WORKFLOW_IR as WorkflowIrV2; + expect(ir.version).toBe("v2"); + }); + + it("contains an ask-user and an exit-gate node ordered before the plan/execute spine", () => { + const ir = BUILTIN_BRAINSTORMING_WORKFLOW_IR as WorkflowIrV2; + + const askUser = ir.nodes.find((node) => node.kind === "ask-user"); + const exitGate = ir.nodes.find((node) => node.kind === "exit-gate"); + expect(askUser?.id).toBe("brainstorm-ask"); + expect(exitGate?.id).toBe("brainstorm-exit"); + + const startIndex = ir.nodes.findIndex((node) => node.id === "start"); + const askIndex = ir.nodes.findIndex((node) => node.id === "brainstorm-ask"); + const refineIndex = ir.nodes.findIndex((node) => node.id === "brainstorm-refine"); + const exitIndex = ir.nodes.findIndex((node) => node.id === "brainstorm-exit"); + const planIndex = ir.nodes.findIndex((node) => node.id === "plan"); + const parseIndex = ir.nodes.findIndex((node) => node.id === "parse"); + const stepsIndex = ir.nodes.findIndex((node) => node.id === "steps"); + + expect(startIndex).toBeGreaterThanOrEqual(0); + expect(askIndex).toBeGreaterThan(startIndex); + expect(refineIndex).toBeGreaterThan(askIndex); + expect(exitIndex).toBeGreaterThan(refineIndex); + expect(planIndex).toBeGreaterThan(exitIndex); + expect(parseIndex).toBeGreaterThan(planIndex); + expect(stepsIndex).toBeGreaterThan(parseIndex); + + // Graph wiring: start feeds the brainstorm loop, and the exit-gate's + // outcome:exit edge is what rejoins the unmodified plan/execute spine. + expect(ir.edges).toContainEqual({ from: "start", to: "brainstorm-ask", condition: "success" }); + expect(ir.edges).toContainEqual({ from: "brainstorm-ask", to: "brainstorm-refine", condition: "success" }); + expect(ir.edges).toContainEqual({ from: "brainstorm-refine", to: "brainstorm-exit", condition: "success" }); + expect(ir.edges).toContainEqual({ from: "brainstorm-exit", to: "plan", condition: "outcome:exit" }); + expect(ir.edges).toContainEqual({ + from: "brainstorm-exit", + to: "brainstorm-ask", + condition: "outcome:continue", + kind: "rework", + }); + + // The exit-gate's rework edge targets a declared top-level rework-region head. + expect(askUser?.config?.reworkRegion).toBe(true); + expect(typeof askUser?.config?.maxReworkCycles).toBe("number"); + + // ask-user carries a non-empty question (validator-enforced when present). + expect(typeof askUser?.config?.question).toBe("string"); + expect((askUser?.config?.question as string).trim().length).toBeGreaterThan(0); + }); + + it("round-trips serialize -> parse to identical bytes", () => { + const serialized = serializeWorkflowIr(BUILTIN_BRAINSTORMING_WORKFLOW_IR); + const reparsed = parseWorkflowIr(serialized); + expect(serializeWorkflowIr(reparsed)).toBe(serialized); + }); + + it("carries the shared built-in workflow settings", () => { + const ir = BUILTIN_BRAINSTORMING_WORKFLOW_IR as WorkflowIrV2; + expect(ir.settings?.some((setting) => setting.id === "planReviewMaxRevisions")).toBe(true); + expect(ir.settings?.some((setting) => setting.id === "codeReviewMaxRevisions")).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index c695958ccf..0fbcff0039 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -343,6 +343,72 @@ describe("built-in workflows", () => { ); }); + /* + * FNXC:WorkflowBrainstorming 2026-07-05-00:00: + * FN-7584 parity coverage for the registered builtin:brainstorming built-in + * (FN-7579's ask-user -> refine -> exit-gate-on-approval composition, + * discoverable from the workflow picker ahead of the normal coding spine). + */ + it("registers builtin:brainstorming as a default-enabled workflow ordered after the existing built-ins", () => { + const brainstorming = getBuiltinWorkflow("builtin:brainstorming"); + expect(brainstorming).toBeDefined(); + expect(brainstorming!.kind).toBe("workflow"); + expect(() => parseWorkflowIr(brainstorming!.ir)).not.toThrow(); + expect(defaultEnabledBuiltinWorkflowIds()).toContain("builtin:brainstorming"); + expect(BUILTIN_WORKFLOWS.findIndex((workflow) => workflow.id === "builtin:brainstorming")).toBeGreaterThan( + BUILTIN_WORKFLOWS.findIndex((workflow) => workflow.id === "builtin:lead-generation"), + ); + // Ordering assertion the suite pins elsewhere (`.slice(0, 5)`) must stay untouched. + expect(defaultEnabledBuiltinWorkflowIds().slice(0, 5)).toEqual([ + "builtin:coding", + "builtin:coding-ideas", + "builtin:legacy-coding", + "builtin:quick-fix", + "builtin:review-heavy", + ]); + }); + + it("orders builtin:brainstorming's ask-user/exit-gate loop ahead of the plan/execute spine", () => { + const brainstorming = getBuiltinWorkflow("builtin:brainstorming")!; + const nodes = brainstorming.ir.nodes; + const askIndex = nodes.findIndex((node) => node.kind === "ask-user"); + const exitIndex = nodes.findIndex((node) => node.kind === "exit-gate"); + const planIndex = nodes.findIndex((node) => node.id === "plan"); + const parseIndex = nodes.findIndex((node) => node.id === "parse"); + const stepsIndex = nodes.findIndex((node) => node.id === "steps"); + + expect(askIndex).toBeGreaterThanOrEqual(0); + expect(exitIndex).toBeGreaterThan(askIndex); + expect(planIndex).toBeGreaterThan(exitIndex); + expect(parseIndex).toBeGreaterThan(planIndex); + expect(stepsIndex).toBeGreaterThan(parseIndex); + }); + + it("builtin:brainstorming still satisfies every merge-capable built-in invariant (completion summary, post-merge-verification, merge primitives, settings)", () => { + const brainstorming = getBuiltinWorkflow("builtin:brainstorming")!; + const summaryNodes = brainstorming.ir.nodes.filter( + (node) => node.kind === "prompt" && (node.config as { summaryTarget?: unknown } | undefined)?.summaryTarget === "task", + ); + expect(summaryNodes).toHaveLength(1); + expect((summaryNodes[0]!.config as { toolMode?: unknown }).toolMode).toBe("readonly"); + + expect(brainstorming.ir.nodes.some((node) => node.id === "post-merge-verification")).toBe(true); + for (const id of [ + "merge-gate", + "merge-retry", + "merge-manual-hold", + "branch-group-member-integration", + "branch-group-promotion", + "merge-attempt", + "recovery-router", + ]) { + expect(brainstorming.ir.nodes.some((node) => node.id === id)).toBe(true); + } + + expect(brainstorming.ir.settings?.some((setting) => setting.id === "planReviewMaxRevisions")).toBe(true); + expect(brainstorming.ir.settings?.some((setting) => setting.id === "codeReviewMaxRevisions")).toBe(true); + }); + it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => { expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); diff --git a/packages/core/src/builtin-brainstorming-workflow-ir.ts b/packages/core/src/builtin-brainstorming-workflow-ir.ts new file mode 100644 index 0000000000..0c69fbc7bf --- /dev/null +++ b/packages/core/src/builtin-brainstorming-workflow-ir.ts @@ -0,0 +1,103 @@ +import type { WorkflowIr, WorkflowIrV2 } from "./workflow-ir-types.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; +import { BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR } from "./builtin-stepwise-final-review-coding-workflow-ir.js"; +import { BUILTIN_WORKFLOW_SETTINGS } from "./builtin-workflow-settings.js"; + +/* +FNXC:WorkflowBrainstorming 2026-07-05-00:00: +FN-7584 closes the loop FN-7579 opened: `ask-user` and `exit-gate` node kinds +existed only as a documented composition (docs/workflow-steps.md, "Brainstorming +/ chat reach-out composition"), not a discoverable built-in. This module clones +the default Coding graph (BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR, +the same IR backing `builtin:coding`) and prepends a bounded brainstorming phase +between `start` and `plan`: + + start -> brainstorm-ask (ask-user: "what do you want to brainstorm?") + -> brainstorm-refine (prompt, seam:"planning": turn the conversation + so far into a refined brief) + -> brainstorm-exit (exit-gate: condition on the ask-user's answer + containing an approval phrase) + outcome:exit -> plan (rejoins the unmodified coding spine) + outcome:continue -> brainstorm-ask (rework edge; the ask-user node + carries config.reworkRegion:true so this is a + legal top-level rework-region head per the U6 + convention validateWorkflowIr enforces) + +The reused coding spine (parse-steps/foreach/step-review, optional +browser-verification/code-review groups, completion-summary, +post-merge-verification, and the full merge-primitive region) is left byte-for-byte +identical to `builtin:coding`'s graph, so every parity invariant that graph +already satisfies carries over unchanged; only the three new brainstorm nodes +and their four edges are new surface for the parity suite to cover. +*/ + +const APPROVAL_PHRASE = "looks good"; + +const RAW_BUILTIN_BRAINSTORMING_WORKFLOW_IR: WorkflowIr = (() => { + const ir = JSON.parse(JSON.stringify(BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR)) as WorkflowIrV2; + ir.name = "builtin-brainstorming"; + + // Insert the three brainstorm nodes right after `start` (order is cosmetic — + // edges determine the walk — but keeping them near `start` mirrors the + // reading order of the graph). + const startIndex = ir.nodes.findIndex((node) => node.id === "start"); + if (startIndex < 0) { + throw new Error("brainstorming built-in requires the cloned graph's start node"); + } + const startColumn = ir.nodes[startIndex]!.column; + const planNode = ir.nodes.find((node) => node.id === "plan"); + const brainstormColumn = planNode?.column ?? startColumn; + + ir.nodes.splice( + startIndex + 1, + 0, + { + id: "brainstorm-ask", + kind: "ask-user", + column: brainstormColumn, + config: { + question: + `What would you like to brainstorm? Share your idea and any constraints; reply "${APPROVAL_PHRASE}" once you're ready to move into planning.`, + // FNXC:WorkflowBrainstorming 2026-07-05-00:00: top-level rework-region + // head (U6 convention) — the loop bound the exit-gate's rework edge + // resets to when it targets this node. + reworkRegion: true, + maxReworkCycles: 5, + }, + }, + { + id: "brainstorm-refine", + kind: "prompt", + column: brainstormColumn, + config: { + seam: "planning", + name: "Refine brainstorm", + prompt: + "You are helping the user brainstorm before this task is planned. Read the task description and the user's latest reply. Turn the conversation so far into a short, structured refined brief: 1) the core idea/goal, 2) key constraints or requirements mentioned so far, 3) open questions still unresolved, 4) a one-line readiness assessment (ready to plan, or what's still missing). Keep it concise — this is a running scratchpad the user reviews each turn, not a final spec.", + }, + }, + { + id: "brainstorm-exit", + kind: "exit-gate", + column: brainstormColumn, + config: { + condition: { type: "output-contains", nodeId: "brainstorm-ask", value: APPROVAL_PHRASE }, + }, + }, + ); + + // Rewire start -> plan through the new brainstorm loop. + ir.edges = ir.edges.filter((edge) => !(edge.from === "start" && edge.to === "plan")); + ir.edges.unshift( + { from: "start", to: "brainstorm-ask", condition: "success" }, + { from: "brainstorm-ask", to: "brainstorm-refine", condition: "success" }, + { from: "brainstorm-refine", to: "brainstorm-exit", condition: "success" }, + { from: "brainstorm-exit", to: "plan", condition: "outcome:exit" }, + { from: "brainstorm-exit", to: "brainstorm-ask", condition: "outcome:continue", kind: "rework" }, + ); + + ir.settings = BUILTIN_WORKFLOW_SETTINGS; + return ir; +})(); + +export const BUILTIN_BRAINSTORMING_WORKFLOW_IR = parseWorkflowIr(RAW_BUILTIN_BRAINSTORMING_WORKFLOW_IR); diff --git a/packages/core/src/builtin-workflows.ts b/packages/core/src/builtin-workflows.ts index 1575033940..1a0cb882df 100644 --- a/packages/core/src/builtin-workflows.ts +++ b/packages/core/src/builtin-workflows.ts @@ -1,5 +1,6 @@ import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; import { BUILTIN_CODING_IDEAS_WORKFLOW_IR } from "./builtin-coding-ideas-workflow-ir.js"; +import { BUILTIN_BRAINSTORMING_WORKFLOW_IR } from "./builtin-brainstorming-workflow-ir.js"; import { BUILTIN_LEAD_GENERATION_WORKFLOW_IR } from "./builtin-lead-generation-workflow-ir.js"; import { BUILTIN_MARKETING_WORKFLOW_IR } from "./builtin-marketing-workflow-ir.js"; import { BUILTIN_PR_WORKFLOW_IR } from "./builtin-pr-workflow-ir.js"; @@ -708,6 +709,51 @@ export const BUILTIN_WORKFLOWS: WorkflowDefinition[] = [ createdAt: BUILTIN_TS, updatedAt: BUILTIN_TS, }, + /* + * FNXC:WorkflowBrainstorming 2026-07-05-00:00: + * FN-7584 registers a discoverable built-in for FN-7579's ask-user -> refine -> + * exit-gate-on-approval brainstorming composition (docs/workflow-steps.md). It + * clones the default Coding graph (BUILTIN_STEPWISE_FINAL_REVIEW_CODING_WORKFLOW_IR) + * and prepends the brainstorm loop between `start` and `plan`, so every downstream + * node/edge is byte-identical to `builtin:coding`. Appended LAST (after + * lead-generation) so it does not disturb the defaultEnabledBuiltinWorkflowIds() + * `.slice(0, 5)` ordering assertion in builtin-workflows.test.ts. + */ + { + id: "builtin:brainstorming", + name: "Brainstorming", + description: + "Brainstorm with the user first: ask a question, refine the idea, and exit the loop on approval, then run the standard coding plan/execute/review/merge pipeline.", + kind: "workflow", + ir: BUILTIN_BRAINSTORMING_WORKFLOW_IR, + layout: { + start: { x: 60, y: 160 }, + "brainstorm-ask": { x: 230, y: 160 }, + "brainstorm-refine": { x: 400, y: 160 }, + "brainstorm-exit": { x: 570, y: 160 }, + plan: { x: 740, y: 160 }, + "plan-review": { x: 910, y: 160 }, + "plan-replan": { x: 910, y: 320 }, + parse: { x: 1080, y: 160 }, + steps: { x: 1250, y: 160 }, + "browser-verification": { x: 1420, y: 160 }, + "browser-verification-remediation": { x: 1420, y: 320 }, + "code-review": { x: 1590, y: 160 }, + "code-review-remediation": { x: 1590, y: 320 }, + "completion-summary": { x: 1760, y: 160 }, + "merge-gate": { x: 1930, y: 160 }, + "branch-group-member-integration": { x: 2100, y: 80 }, + "branch-group-promotion": { x: 2270, y: 80 }, + "merge-attempt": { x: 2440, y: 160 }, + "merge-retry": { x: 2610, y: 80 }, + "recovery-router": { x: 2610, y: 240 }, + "merge-manual-hold": { x: 2100, y: 240 }, + "post-merge-verification": { x: 2780, y: 160 }, + end: { x: 2950, y: 160 }, + }, + createdAt: BUILTIN_TS, + updatedAt: BUILTIN_TS, + }, ]; const BUILTIN_BY_ID = new Map(BUILTIN_WORKFLOWS.map((wf) => [wf.id, wf])); diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index c70d94b256..9dfbf10cad 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -4754,7 +4754,23 @@ function InnerEditor({ "This workflow is waiting for your input.", )} value={String(selectedNode.data.config?.question ?? "")} - onChange={(e) => updateSelectedData({ config: { question: e.target.value } })} + onChange={(e) => { + const value = e.target.value; + // FNXC:WorkflowAskUser 2026-07-05-02:00: validateAskUserAndExitGateNodes + // rejects a PRESENT-but-empty `question` (only an ABSENT question falls + // back to the engine default). Clearing the textarea back to "" must + // therefore delete the key, not persist `question: ""`, or the node + // silently becomes unsavable the moment an author reverts to the + // documented "leave blank for the default" behavior. + updateSelectedData({ + config: (prev) => { + const next = { ...prev }; + if (value.trim() === "") delete next.question; + else next.question = value; + return next; + }, + }); + }} />