diff --git a/.changeset/fn-7579-ask-user-exit-gate-nodes.md b/.changeset/fn-7579-ask-user-exit-gate-nodes.md new file mode 100644 index 0000000000..66172c6c82 --- /dev/null +++ b/.changeset/fn-7579-ask-user-exit-gate-nodes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add "Ask user question" and "Exit gate" workflow nodes for mid-flow chat reach-out and early exit. +category: feature +dev: New IR node kinds `ask-user` (reuses await-input park/resume; surfaces the question in the task chat) and `exit-gate` (terminates the workflow early, optional condition). Editor palette + summaries + help updated; `prompt`+`awaitInput` remains a back-compat alias. diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index eb89aace9e..3100eea654 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -356,6 +356,34 @@ Parallelism is opt-in *per step by the planner*, not asserted by the workflow au Notification delivery is intentionally best-effort: a missing/unconfigured notification service, an empty `event`, or a provider delivery failure is logged/audited but does not fail the workflow node. Providers receive the rendered title/message in notification metadata so ntfy and webhook notifications can show workflow-specific copy. `workflow-notify` is **not** part of the default ntfy event allowlist; add it to `ntfyEvents` or the provider `events` filter when you want workflow-authored notifications delivered. +#### `ask-user` node — chat reach-out (FN-7579) + +`ask-user` (`{ question? }`) reaches out to the user from inside a running task: it parks the task with `status: "awaiting-user-input"`, `paused: true`, and `pausedReason` carrying a `workflow-input:@: ` marker; the question surfaces in the task chat/detail (and via the `planning-awaiting-input` notification). Once the user replies (a steering comment at/after the pause watermark) and unpauses the task, the node resumes, clears its marker, and publishes the answer downstream at context key `input:` (readable by, for example, a downstream `exit-gate`'s condition). `question` falls back to `config.prompt`, then the shared default string ("This workflow is waiting for your input.") when both are omitted. + +`ask-user` is a first-class, discoverable promotion of plumbing that already existed: a `prompt` node with `config.awaitInput: true` pauses/resumes identically (`runAwaitInputNode`). That shape remains a **fully supported back-compat alias** — existing workflows using it are unaffected — `ask-user` is simply the dedicated palette entry and IR node kind for new authoring. + +#### `exit-gate` node — early workflow termination (FN-7579) + +`exit-gate` (`{ condition? }`) lets a workflow route directly to the terminal `end` node instead of always walking the full graph. It is validated to always have a (transitive, non-rework) path to `end` so it can never strand the graph, but it is **not** itself an `end` node — only a router onto one. + +With no `condition`, an exit-gate always exits (`outcome:exit`). With a `condition` (the same shape as a `loop` node's `exitWhen`: `{ type: "output-contains", nodeId?, value }` or `{ type: "output-matches", nodeId?, pattern, flags? }`), the gate reads `context["input:"]` — the same key an `ask-user` node's answer is published under — and exits (`outcome:exit`) when it matches, or falls through (`outcome:continue`) otherwise. Route `outcome:exit` to `end` and `outcome:continue` back into the loop (or onward) as needed. A malformed condition (bad regex, missing referenced value) degrades to "no match" rather than throwing. + +#### Brainstorming / chat reach-out composition + +Compose `ask-user` + `exit-gate` for a brainstorming phase that loops until the user approves, then proceeds: + +``` +start → ask (ask-user: "Anything to refine?") + → exit (exit-gate: condition { type: "output-contains", nodeId: "ask", value: "looks good" }) + ── outcome:exit ──→ end (or onward into the normal plan/execute path) + ── outcome:continue ──→ ask (rework edge back to the ask-user node; mark the ask-user + node `config.reworkRegion: true` and the edge `kind: "rework"`, + mirroring the top-level rework-region convention U6 uses for + 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`. + #### Workflow-defined custom task fields Workflows declare typed task fields via IR `fields: [{ id, name, type, required?, default?, options?, render? }]` (`type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options` for enum kinds; `render.placement ∈ card | detail | detail-section`, `render.widget`, `render.badge`). Values live in `tasks.customFields` and are validated through a single store authority (`updateTaskCustomFields`) with typed rejections (offending `fieldId` + `code`). Editing or switching a workflow **orphans** (never destroys) values for removed/incompatible fields — orphans are retained and shown under a detail disclosure. The task UI renders the schema dynamically (detail-form widgets by type, up to 3 card badges by placement). Agents read/write fields via `fn_task_update`'s `custom_fields` patch; authors set them via `fn_workflow_create/update`. Field values are surfaced in task/session context. diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts index b1f28d1277..9b5624250b 100644 --- a/packages/core/src/__tests__/workflow-ir.test.ts +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -439,6 +439,126 @@ describe("parseWorkflowIr — notify nodes", () => { }); }); +describe("parseWorkflowIr — ask-user / exit-gate nodes (FN-7579)", () => { + const cols = [{ id: "c", name: "C", traits: [] }]; + + it("accepts a well-formed graph using both new kinds", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c", config: { question: "Looks good?" } }, + { id: "exit", kind: "exit-gate", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "exit" }, + { from: "exit", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("keeps v2 when ask-user/exit-gate nodes are present (v2-only, not in V1_NODE_KINDS)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c" }, + { id: "exit", kind: "exit-gate", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "exit" }, + { from: "exit", to: "end" }, + ], + ); + const parsed = parseWorkflowIr(ir); + expect(downgradeIrToV1IfPure(parsed).version).toBe("v2"); + }); + + it("rejects an ask-user node with an empty question", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c", config: { question: " " } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/ask-user node 'ask' question must be a non-empty string/); + }); + + it("accepts an ask-user node with no question (falls back to the default prompt)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "ask", kind: "ask-user", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("rejects an exit-gate node that cannot reach the terminal end node (stranded)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "exit", kind: "exit-gate", column: "c" }, + { id: "dead", kind: "prompt", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "exit" }, + { from: "exit", to: "dead" }, + { from: "start", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/exit-gate node 'exit' must have a path to the terminal 'end' node/); + }); + + it("accepts a brainstorming loop composition: ask-user -> exit-gate (approved) or back to ask-user (refine)", () => { + const ir = v2( + cols, + [ + { id: "start", kind: "start", column: "c" }, + { + id: "ask", + kind: "ask-user", + column: "c", + config: { question: "Anything to refine?", reworkRegion: true }, + }, + { + id: "exit", + kind: "exit-gate", + column: "c", + config: { condition: { type: "output-contains", value: "looks good" } }, + }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "ask" }, + { from: "ask", to: "exit" }, + { from: "exit", to: "end" }, + { from: "exit", to: "ask", kind: "rework" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + describe("parseWorkflowIr — hold release kinds", () => { const holdCols = [{ id: "c", name: "C", traits: [] }]; function holdIr(release: unknown): WorkflowIrV2 { diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index 0da9d5b6e6..46c39f2f11 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -11,7 +11,13 @@ * `branch-group-promotion`; * and the unified PR-entity additions (U3): * `pr-create` (open/reuse the PR + write the entity), `pr-respond` (the - * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid). */ + * review-response run), and `pr-merge` (tool-side merge with expectedHeadOid); + * and the brainstorming / chat reach-out additions (FN-7579): + * `ask-user` (first-class surface over the existing await-input park/resume + * plumbing — parks the task awaiting a user reply and surfaces `config.question` + * in the task chat/detail) and `exit-gate` (routes the walk early to the + * terminal `end` node when `config.condition` matches, or unconditionally when + * absent — lets a workflow break out of a brainstorming loop once approved). */ export type WorkflowIrNodeKind = | "start" | "prompt" @@ -37,7 +43,9 @@ export type WorkflowIrNodeKind = | "branch-group-promotion" | "pr-create" | "pr-respond" - | "pr-merge"; + | "pr-merge" + | "ask-user" + | "exit-gate"; export interface WorkflowIrNode { id: string; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 782a3fc4e4..47f0adc173 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1034,6 +1034,52 @@ function validateNotifyNodes(nodes: WorkflowIrNode[]): void { } } +/* +FNXC:WorkflowAskUserExitGate 2026-07-05-00:00: +FN-7579 adds two brainstorming/chat reach-out node kinds. `ask-user` reuses the +existing await-input park/resume plumbing (runAwaitInputNode) rather than a new +runner: it must carry a non-empty `config.question` (falling back to +`config.prompt`) OR omit both, in which case the engine's existing default +question string is used — validation only rejects a present-but-empty/non-string +value so authors cannot ship a blank prompt. `exit-gate` terminates the walk +early toward the terminal `end` node: it must have at least one outgoing edge +that (transitively, ignoring rework edges) reaches `end`, so an exit-gate can +never strand the graph. It is NOT itself an `end` node (the one-start/one-end +invariant is unaffected) — it only routes to one. +*/ +function validateAskUserAndExitGateNodes( + nodes: WorkflowIrNode[], + outgoing: Map, +): void { + const endNode = nodes.find((n) => n.kind === "end"); + + for (const node of nodes) { + if (node.kind === "ask-user") { + const cfg = node.config as { question?: unknown; prompt?: unknown } | undefined; + if (cfg?.question !== undefined && (typeof cfg.question !== "string" || cfg.question.trim() === "")) { + throw new WorkflowIrError( + `ask-user node '${node.id}' question must be a non-empty string when present`, + ); + } + if (cfg?.prompt !== undefined && (typeof cfg.prompt !== "string" || cfg.prompt.trim() === "")) { + throw new WorkflowIrError( + `ask-user node '${node.id}' prompt must be a non-empty string when present`, + ); + } + } + + if (node.kind === "exit-gate") { + if (!endNode) continue; // exactly-one-end invariant already failed elsewhere. + const reachable = reachableFrom(node.id, outgoing); + if (!reachable.has(endNode.id)) { + throw new WorkflowIrError( + `exit-gate node '${node.id}' must have a path to the terminal 'end' node`, + ); + } + } + } +} + /** Validate `fields` declarations (KTD-13). */ function validateFields(fields: WorkflowFieldDefinition[] | undefined): void { if (fields === undefined) return; @@ -1432,6 +1478,7 @@ function validateV2(ir: WorkflowIrV2): void { validateParseStepsNodes(ir); validateCodeNodes(ir.nodes); validateNotifyNodes(ir.nodes); + validateAskUserAndExitGateNodes(ir.nodes, outgoing); validateFields(ir.fields); validateSettings(ir.settings); // FNXC:WorkflowOptionalGroup 2026-06-21-18:00: diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index c540d10fbe..c70d94b256 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -18,7 +18,7 @@ import { } from "@xyflow/react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ToggleRight, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2, DoorOpen } from "lucide-react"; import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowIrNodeKind } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { @@ -263,7 +263,19 @@ const WORKFLOW_NOTIFY_MESSAGE_PLACEHOLDER = "Task {{taskId}} reached {{workflowN const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof MessageSquare; presetConfig?: Record }> = [ { kind: "prompt", label: "Prompt", icon: MessageSquare }, - { kind: "prompt", label: "User input", icon: HelpCircle, presetConfig: { awaitInput: true } }, + // FNXC:WorkflowAskUser 2026-07-05-00:00: FN-7579 promotes the formerly generic + // "User input" entry (a `prompt` node with a hidden `awaitInput: true` preset) + // into the first-class "Ask user question" node (`kind: "ask-user"`). The old + // `prompt` + `config.awaitInput: true` shape still validates and parks/resumes + // unchanged (back-compat alias) — it is just no longer offered from the + // palette, so there is one discoverable entry point, not two. + // FNXC:WorkflowAskUser 2026-07-05-01:30: no presetConfig here — an ask-user + // node's `config.question` key must be ABSENT (not an empty string) to fall + // back to the engine's default prompt; validateAskUserAndExitGateNodes + // rejects a present-but-empty question, so seeding `{ question: "" }` here + // would make every freshly dropped, untouched node fail to save. + { kind: "ask-user", label: "Ask user question", icon: HelpCircle }, + { kind: "exit-gate", label: "Exit gate", icon: DoorOpen }, { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, @@ -331,6 +343,8 @@ const USER_NODE_KINDS: ReadonlySet = new Set +