FN-7579: add ask-user and exit-gate workflow nodes

Add workflow nodes for mid-flow user reach-out and early exit from a workflow run.

- Add `ask-user` IR node kind that reuses the await-input park/resume mechanism and surfaces the question in the task chat for brainstorming/clarification.
- Add `exit-gate` IR node kind that terminates the workflow early, with an optional condition.
- Wire both node kinds through the engine executor and workflow-node-handlers, including a new exit-gate-runner.
- Update the WorkflowNodeEditor palette, node summaries, and node help text for the two new node types.
- Extend workflow-flow-mapping to support the new node kinds.
- Keep `prompt`+`awaitInput` as a back-compat alias.
- Add core/engine/dashboard tests covering the new node kinds.
- Document the new nodes in docs/workflow-steps.md.
- Add changeset for the new minor feature.

Files changed:
 .changeset/fn-7579-ask-user-exit-gate-nodes.md     |   7 +
 docs/workflow-steps.md                             |  28 ++++
 packages/core/src/__tests__/workflow-ir.test.ts    | 120 ++++++++++++++
 packages/core/src/workflow-ir-types.ts             |  12 +-
 packages/core/src/workflow-ir.ts                   |  47 ++++++
 .../app/components/WorkflowNodeEditor.tsx          | 181 ++++++++++++++++++++-
 .../app/components/__tests__/node-summary.test.ts  |  43 +++++
 .../__tests__/workflow-flow-mapping.test.ts        |  49 ++++++
 .../app/components/nodes/WorkflowNodeTypes.tsx     |  14 +-
 .../dashboard/app/components/nodes/node-help.ts    |  24 +++
 .../dashboard/app/components/nodes/node-summary.ts |  28 ++++
 .../app/components/workflow-flow-mapping.ts        |   4 +
 .../workflow-graph-executor-handlers.test.ts       | 115 +++++++++++++
 .../src/__tests__/workflow-node-handlers.test.ts   |  66 ++++++++
 packages/engine/src/executor.ts                    |  23 ++-
 packages/engine/src/workflow-node-handlers.ts      |  18 +-
 .../src/workflow-node-runners/exit-gate-runner.ts  |  81 +++++++++
 17 files changed, 849 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-7579
Fusion-Task-Lineage: 9a89ff49-200d-4a6c-b97c-15d219349ee5
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 11:10:02 -07:00
parent 74358494a7
commit 42bbe58c03
17 changed files with 849 additions and 11 deletions

View File

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

View File

@@ -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:<nodeId>@<pauseEpochMs>: <question>` 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:<nodeId>` (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:<nodeId>"]` — 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.

View File

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

View File

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

View File

@@ -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<string, WorkflowIrEdge[]>,
): 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:

View File

@@ -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<string, unknown> }> = [
{ 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<WorkflowEditorNodeKind> = new Set<WorkflowEdi
"parse-steps",
"notify",
"merge",
"ask-user",
"exit-gate",
]);
/** A pickable creation template: "Blank" (id null) or a copyable source
@@ -4726,6 +4740,169 @@ function InnerEditor({
})()
) : null}
{/* FNXC:WorkflowAskUser 2026-07-05-00:00: the ask-user question is the only
author-facing field; it reuses the same default-string fallback the
engine's runAwaitInputNode applies when left blank. */}
{selectedNode.data.kind === "ask-user" ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.askUserQuestion", "Question")}</span>
<textarea
rows={3}
placeholder={t(
"workflowNodes.askUserQuestionPlaceholder",
"This workflow is waiting for your input.",
)}
value={String(selectedNode.data.config?.question ?? "")}
onChange={(e) => updateSelectedData({ config: { question: e.target.value } })}
/>
</label>
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.askUserNote",
"Parks the task and surfaces this question in the task chat/detail. Resumes once the user replies and unpauses.",
)}
</p>
</>
) : null}
{/* FNXC:WorkflowExitGate 2026-07-05-00:00: condition is optional — an
unconditional exit-gate always routes to end; a conditional one checks
a referenced node's published input (e.g. an ask-user reply). */}
{selectedNode.data.kind === "exit-gate" ? (
(() => {
const hasCondition =
selectedNode.data.config?.condition != null &&
typeof selectedNode.data.config.condition === "object";
const condition = hasCondition
? (selectedNode.data.config!.condition as Record<string, unknown>)
: { type: "output-contains", value: "" };
const condType = String(condition.type ?? "output-contains");
const condText = condType === "output-matches" ? String(condition.pattern ?? "") : String(condition.value ?? "");
return (
<>
<label className="wf-field wf-field--checkbox">
<input
type="checkbox"
checked={hasCondition}
onChange={(e) => {
if (e.target.checked) {
updateSelectedData({
config: { condition: { type: "output-contains", value: "" } },
});
} else {
updateSelectedData({
config: (prev) => {
const next = { ...prev };
delete next.condition;
return next;
},
});
}
}}
/>
<span>{t("workflowNodes.exitGateConditional", "Exit conditionally")}</span>
</label>
{hasCondition ? (
<>
<label className="wf-field">
<span>{t("workflowNodes.exitGateNodeId", "Watch node id")}</span>
<input
value={String(condition.nodeId ?? "")}
placeholder={t("workflowNodes.exitGateNodeIdPlaceholder", "e.g. ask")}
onChange={(e) => {
const nodeId = e.target.value.trim();
updateSelectedData({
config: (prev) => {
const current =
prev.condition && typeof prev.condition === "object"
? (prev.condition as Record<string, unknown>)
: { type: "output-contains", value: "" };
const next = { ...current };
if (nodeId) next.nodeId = nodeId;
else delete next.nodeId;
return { ...prev, condition: next };
},
});
}}
/>
</label>
<label className="wf-field">
<span>{t("workflowNodes.exitGateConditionType", "Exit when")}</span>
<select
value={condType}
onChange={(e) => {
const nextType = e.target.value;
updateSelectedData({
config: (prev) => {
const current =
prev.condition && typeof prev.condition === "object"
? (prev.condition as Record<string, unknown>)
: {};
const text =
nextType === "output-matches"
? String(current.pattern ?? current.value ?? "")
: String(current.value ?? current.pattern ?? "");
const nextCondition: Record<string, unknown> = { ...current, type: nextType };
delete nextCondition.pattern;
delete nextCondition.value;
return {
...prev,
condition:
nextType === "output-matches"
? { ...nextCondition, pattern: text }
: { ...nextCondition, value: text },
};
},
});
}}
>
<option value="output-contains">{t("workflowNodes.loopOutputContains", "Output contains")}</option>
<option value="output-matches">{t("workflowNodes.loopOutputMatches", "Output matches regex")}</option>
</select>
</label>
<label className="wf-field">
<span>
{condType === "output-matches"
? t("workflowNodes.loopPattern", "Pattern")
: t("workflowNodes.loopValue", "Value")}
</span>
<input
value={condText}
placeholder={condType === "output-matches" ? "looks good|approved" : "looks good"}
onChange={(e) => {
const value = e.target.value;
updateSelectedData({
config: (prev) => {
const current =
prev.condition && typeof prev.condition === "object"
? (prev.condition as Record<string, unknown>)
: {};
return {
...prev,
condition:
condType === "output-matches"
? { ...current, type: condType, pattern: value }
: { ...current, type: condType, value },
};
},
});
}}
/>
</label>
</>
) : null}
<p className="wf-inspector-note wf-inspector-note--info">
{t(
"workflowNodes.exitGateNote",
"Routes early to End (outcome:exit) when the condition matches, or unconditionally when none is set. Falls through (outcome:continue) otherwise.",
)}
</p>
</>
);
})()
) : null}
{selectedNode.data.kind === "prompt" ||
selectedNode.data.kind === "gate" ||
selectedNode.data.kind === "script" ? (

View File

@@ -26,4 +26,47 @@ describe("nodeConfigSummary", () => {
expect(nodeConfigSummary(data)).toBe("workflow-notify · This message is intentionally long enou…");
});
// FN-7579
it("summarizes an ask-user node by its question", () => {
const data: WorkflowFlowNodeData = {
kind: "ask-user",
label: "Ask user question",
config: { question: "Anything to refine before we finish?" },
};
expect(nodeConfigSummary(data)).toBe("Anything to refine before we finish?");
});
it("falls back to the default prompt summary when an ask-user node has no question", () => {
const data: WorkflowFlowNodeData = { kind: "ask-user", label: "Ask user question", config: {} };
expect(nodeConfigSummary(data)).toBe("Waits for user input");
});
it("summarizes an unconditional exit-gate", () => {
const data: WorkflowFlowNodeData = { kind: "exit-gate", label: "Exit gate", config: {} };
expect(nodeConfigSummary(data)).toBe("Always exits");
});
it("summarizes a conditional exit-gate (output-contains)", () => {
const data: WorkflowFlowNodeData = {
kind: "exit-gate",
label: "Exit gate",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
};
expect(nodeConfigSummary(data)).toBe('Exits when contains "looks good"');
});
it("summarizes a conditional exit-gate (output-matches)", () => {
const data: WorkflowFlowNodeData = {
kind: "exit-gate",
label: "Exit gate",
config: { condition: { type: "output-matches", nodeId: "ask", pattern: "approve(d)?" } },
};
expect(nodeConfigSummary(data)).toBe("Exits when matches /approve(d)?/");
});
});

View File

@@ -490,6 +490,53 @@ describe("workflow-flow-mapping v2 round-trip", () => {
config: { event: "workflow-notify", title: "{{taskTitle}}", message: "Task {{taskId}}" },
});
});
// FN-7579: ask-user and exit-gate round-trip like any other first-class editor node.
it("round-trips ask-user and exit-gate nodes (brainstorming composition) losslessly", () => {
const brainstormIr: WorkflowDefinition["ir"] = {
version: "v2",
name: "brainstorm-wf",
columns: [{ id: "todo", name: "Todo", traits: [] }],
nodes: [
{ id: "start", kind: "start", column: "todo" },
{ id: "ask", kind: "ask-user", column: "todo", config: { question: "Anything to refine?" } },
{
id: "exit",
kind: "exit-gate",
column: "todo",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
},
{ id: "end", kind: "end", column: "todo" },
],
edges: [
{ from: "start", to: "ask", condition: "success" },
{ from: "ask", to: "exit", condition: "success" },
{ from: "exit", to: "end", condition: "outcome:exit" },
],
};
const { nodes, edges } = irToFlow(v2Def(brainstormIr));
const askNode = nodes.find((node) => node.id === "ask");
expect(askNode?.type).toBe("ask-user");
expect(askNode?.data.kind).toBe("ask-user");
const exitNode = nodes.find((node) => node.id === "exit");
expect(exitNode?.type).toBe("exit-gate");
expect(exitNode?.data.kind).toBe("exit-gate");
const { ir: out } = flowToIr("brainstorm-wf", nodes, edges, columnsOf(v2Def(brainstormIr)));
expect(out.version).toBe("v2");
if (out.version !== "v2") return;
expect(out.nodes.find((node) => node.id === "ask")).toMatchObject({
kind: "ask-user",
column: "todo",
config: { question: "Anything to refine?" },
});
expect(out.nodes.find((node) => node.id === "exit")).toMatchObject({
kind: "exit-gate",
column: "todo",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
});
});
});
describe("workflow-flow-mapping validation helpers", () => {
@@ -605,6 +652,8 @@ const VALID_EDITOR_NODE_KINDS: readonly WorkflowEditorNodeKind[] = [
"parse-steps",
"code",
"notify",
"ask-user",
"exit-gate",
];
const IR_ONLY_EDITOR_KIND = {

View File

@@ -1,5 +1,5 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell, ToggleRight } from "lucide-react";
import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle, Repeat, ClipboardCheck, ListChecks, Code2, Bell, ToggleRight, HelpCircle, DoorOpen } from "lucide-react";
import { useTranslation } from "react-i18next";
import { nodeConfigSummary } from "./node-summary";
import { useWorkflowEditorCatalogs } from "./WorkflowEditorCatalogContext";
@@ -34,7 +34,13 @@ export type WorkflowEditorNodeKind =
| WorkflowNodeKindStepReview
| WorkflowNodeKindParseSteps
| "code"
| "notify";
| "notify"
// FN-7579: brainstorming / chat reach-out additions. `ask-user` parks the
// task awaiting a user reply (first-class surface over the await-input
// park/resume plumbing); `exit-gate` routes the walk early to the terminal
// `end` node.
| "ask-user"
| "exit-gate";
export interface WorkflowFlowNodeData {
kind: WorkflowEditorNodeKind;
@@ -80,6 +86,8 @@ const KIND_ICON: Record<WorkflowEditorNodeKind, typeof Play> = {
[WORKFLOW_NODE_KIND_PARSE_STEPS]: ListChecks,
code: Code2,
notify: Bell,
"ask-user": HelpCircle,
"exit-gate": DoorOpen,
};
/** Shared error-state component (U10): one component renders both the
@@ -270,4 +278,6 @@ export const workflowNodeTypes = {
"parse-steps": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="parse-steps" />,
code: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="code" />,
notify: ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="notify" />,
"ask-user": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="ask-user" />,
"exit-gate": ({ data }: NodeProps) => <NodeShell data={data as WorkflowFlowNodeData} kind="exit-gate" />,
};

View File

@@ -193,6 +193,30 @@ const NODE_HELP: Record<string, NodeHelp> = {
edges: "One outgoing edge (success); the node is pass-through.",
},
// FN-7579: brainstorming / chat reach-out nodes.
"ask-user": {
title: "Ask user question",
summary:
"Reaches out to the user from inside the running task: parks the task awaiting a reply, surfaces the Question in the task chat/detail, and resumes the flow once the user replies and unpauses. First-class surface over the same park/resume plumbing a Prompt node's Wait for user input option uses.",
configure:
'Write the Question the user is asked. Leave it blank to use the default "This workflow is waiting for your input" prompt.',
inputs: "The task at this point in the flow.",
outputs: "The user's reply, available downstream as this node's input context.",
edges:
"One outgoing edge (success), taken once the user replies. Compose with an Exit gate for a brainstorm ask → refine → exit-when-approved loop.",
},
"exit-gate": {
title: "Exit gate",
summary:
"Lets a workflow terminate early — routing straight to the terminal End node — instead of always walking the full graph. Useful for breaking out of a brainstorming ask-user/refine loop once the user approves.",
configure:
"Optionally set an Exit condition (output contains / output matches regex) checked against a referenced node's input (e.g. an Ask user question's reply). Leave unset for an unconditional exit.",
inputs: "The task plus prior context (e.g. an Ask user question's reply).",
outputs: "An exit / continue decision.",
edges:
"outcome:exit → routes to End; outcome:continue → falls through to the next node (e.g. back into the brainstorm loop).",
},
// ── Graph-only (engine-managed) IR kinds ──────────────────────────────────
"merge-gate": {
title: "Auto-merge gate",

View File

@@ -239,6 +239,34 @@ export function nodeConfigSummary(
const message = truncate(firstLine(str(config.message)), COMMAND_TRUNCATE);
return message ? `${event} · ${message}` : event;
}
// FN-7579: ask-user surfaces its question (or the shared default prompt
// when omitted); exit-gate mirrors loop's exitWhen summary (unconditional
// when config.condition is absent).
case "ask-user": {
const question = str(config.question) || str(config.prompt);
return question
? truncate(firstLine(question), COMMAND_TRUNCATE)
: t("workflowNodes.summaryAskUserDefault", "Waits for user input");
}
case "exit-gate": {
const condition = config.condition as unknown;
if (!condition || typeof condition !== "object") {
return t("workflowNodes.summaryExitGateUnconditional", "Always exits");
}
const c = condition as Record<string, unknown>;
const type = str(c.type);
if (type === "output-matches") {
return t("workflowNodes.summaryExitGateMatches", "Exits when matches /{{pattern}}/", {
pattern: str(c.pattern),
});
}
if (type === "output-contains") {
return t('workflowNodes.summaryExitGateContains', 'Exits when contains "{{value}}"', {
value: str(c.value),
});
}
return t("workflowNodes.summaryExitGateUnconditional", "Always exits");
}
// No meaningful summary: structural/control nodes.
case "start":
case "end":

View File

@@ -189,6 +189,10 @@ const SAME_KIND_EDITOR_NODE_KINDS = new Set<WorkflowIrNodeKind>([
"parse-steps",
"code",
"notify",
// FN-7579: brainstorming / chat reach-out node kinds round-trip IR ↔ editor
// like any other user-authored node.
"ask-user",
"exit-gate",
]);
const GRAPH_ONLY_EDITOR_KIND: Partial<Record<WorkflowIrNodeKind, WorkflowEditorNodeKind>> = {

View File

@@ -424,4 +424,119 @@ describe("WorkflowGraphExecutor traversal", () => {
await expect(executor.run(task, settingsOn(), ir)).rejects.toThrow("Cycle detected");
});
// FN-7579: ask-user (chat reach-out) + exit-gate (early termination) end-to-end
// through the real registered handlers (no override), using deps.runCustomNode
// exactly as the ask-user node is dispatched in production.
describe("ask-user / exit-gate (FN-7579)", () => {
it("ask-user node parks the task awaiting-user-input via the custom-node runner", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "ask-user",
nodes: [
{ id: "start", kind: "start" },
{ id: "ask", kind: "ask-user", config: { question: "Looks good?" } },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "ask" },
{ from: "ask", to: "end", condition: "success" },
],
};
const runCustomNode = vi.fn(async () => ({ outcome: "failure" as const, value: "awaiting-user-input" }));
const executor = new WorkflowGraphExecutor({ runCustomNode });
const result = await executor.run(task, settingsOn(), ir);
expect(runCustomNode).toHaveBeenCalledOnce();
expect(runCustomNode.mock.calls[0][0]).toMatchObject({ id: "ask", kind: "ask-user" });
expect(result.outcome).toBe("failure");
expect(result.visitedNodeIds).not.toContain("end");
});
it("unconditional exit-gate terminates early, skipping downstream nodes", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "exit-gate-unconditional",
nodes: [
{ id: "start", kind: "start" },
{ id: "exit", kind: "exit-gate" },
{ id: "never", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "exit" },
{ from: "exit", to: "end", condition: "outcome:exit" },
{ from: "exit", to: "never", condition: "outcome:continue" },
],
};
const never = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({ handlers: { prompt: never } });
const result = await executor.run(task, settingsOn(), ir);
expect(never).not.toHaveBeenCalled();
expect(result.outcome).toBe("success");
expect(result.visitedNodeIds).toContain("exit");
expect(result.visitedNodeIds).not.toContain("never");
});
it("conditional exit-gate falls through to the next node when the condition does not match", async () => {
const ir: WorkflowIr = {
version: "v1",
name: "exit-gate-conditional",
nodes: [
{ id: "start", kind: "start" },
{ id: "exit", kind: "exit-gate", config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } } },
{ id: "refine", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "exit" },
{ from: "exit", to: "end", condition: "outcome:exit" },
{ from: "exit", to: "refine", condition: "outcome:continue" },
],
};
const refine = vi.fn(async () => ({ outcome: "success" as const }));
const executor = new WorkflowGraphExecutor({
handlers: { prompt: refine },
});
const result = await executor.run(task, settingsOn(), ir);
expect(refine).toHaveBeenCalledOnce();
expect(result.visitedNodeIds).toContain("refine");
});
it("conditional exit-gate exits early when the referenced context value matches", async () => {
// Seed the ask-user answer via runCustomNode's contextPatch by running a
// graph that first visits an ask-user node, then the exit-gate reads its
// published `input:ask` context key.
const irWithAsk: WorkflowIr = {
version: "v1",
name: "exit-gate-conditional-match-full",
nodes: [
{ id: "start", kind: "start" },
{ id: "ask", kind: "ask-user", config: { question: "Anything to refine?" } },
{ id: "exit", kind: "exit-gate", config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } } },
{ id: "refine", kind: "prompt" },
{ id: "end", kind: "end" },
],
edges: [
{ from: "start", to: "ask" },
{ from: "ask", to: "exit", condition: "success" },
{ from: "exit", to: "end", condition: "outcome:exit" },
{ from: "exit", to: "refine", condition: "outcome:continue" },
],
};
const refine = vi.fn(async () => ({ outcome: "success" as const }));
const runCustomNode = vi.fn(async () => ({
outcome: "success" as const,
contextPatch: { "input:ask": "yes, looks good to me" },
}));
const executor2 = new WorkflowGraphExecutor({ runCustomNode, handlers: { prompt: refine } });
const result = await executor2.run(task, settingsOn(), irWithAsk);
expect(refine).not.toHaveBeenCalled();
expect(result.visitedNodeIds).toContain("exit");
expect(result.visitedNodeIds).not.toContain("refine");
});
});
});

View File

@@ -125,4 +125,70 @@ describe("workflow node handlers", () => {
expect(result.outcome).toBe("success");
expect(runCustomNode).not.toHaveBeenCalled();
});
// FN-7579: ask-user is registered on the SAME custom-node seam as prompt/script
// (no dedicated seam string) so it always falls through to the custom-node
// runner, which is where the engine special-cases node.kind === "ask-user"
// onto the await-input park/resume path (covered end-to-end in
// workflow-graph-executor-handlers.test.ts).
it("dispatches an ask-user node to the custom-node runner (no seam)", async () => {
const runCustomNode = vi.fn(async () => ({ outcome: "failure" as const, value: "awaiting-user-input" }));
const handlers = createDefaultNodeHandlers(noopSeams(), runCustomNode);
const askNode: WorkflowIrNode = { id: "ask", kind: "ask-user", config: { question: "Anything to refine?" } };
const result = await handlers["ask-user"](askNode, { task, settings: undefined, context: {} });
expect(runCustomNode).toHaveBeenCalledWith(askNode, task, {});
expect(result).toEqual({ outcome: "failure", value: "awaiting-user-input" });
});
describe("exit-gate handler", () => {
it("exits unconditionally when no condition is configured", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{ id: "exit", kind: "exit-gate", config: {} },
{ task, settings: undefined, context: {} },
);
expect(result).toEqual({ outcome: "success", value: "exit" });
});
it("exits when an output-contains condition matches the referenced node's context value", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{
id: "exit",
kind: "exit-gate",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
},
{ task, settings: undefined, context: { "input:ask": "yes, looks good to me" } },
);
expect(result).toEqual({ outcome: "success", value: "exit" });
});
it("falls through (does not exit) when the condition does not match", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{
id: "exit",
kind: "exit-gate",
config: { condition: { type: "output-contains", nodeId: "ask", value: "looks good" } },
},
{ task, settings: undefined, context: { "input:ask": "needs more work" } },
);
expect(result).toEqual({ outcome: "success", value: "continue" });
});
it("exits when an output-matches regex condition matches", async () => {
const handlers = createDefaultNodeHandlers(noopSeams());
const result = await handlers["exit-gate"](
{
id: "exit",
kind: "exit-gate",
config: { condition: { type: "output-matches", nodeId: "ask", pattern: "approve(d)?", flags: "i" } },
},
{ task, settings: undefined, context: { "input:ask": "Approved!" } },
);
expect(result).toEqual({ outcome: "success", value: "exit" });
});
});
});

View File

@@ -6647,9 +6647,21 @@ export class TaskExecutor {
* placement re-walks earlier read-only nodes until CU-U5 checkpoints land.
*/
private async runAwaitInputNode(node: WorkflowIrNode, live: TaskDetail): Promise<WorkflowNodeResult> {
const question = typeof node.config?.prompt === "string" && node.config.prompt.trim()
? node.config.prompt.trim()
: "This workflow is waiting for your input.";
/*
FNXC:WorkflowAskUser 2026-07-05-00:00:
FN-7579's `ask-user` node is the first-class discoverable surface over this
SAME park/resume plumbing that a `prompt` node with `config.awaitInput: true`
already used. Question resolution order: `config.question` (the ask-user
node's dedicated field) first, then `config.prompt` (back-compat with the
original awaitInput alias), then the shared default string. Nothing below
this line branches on node.kind — both node kinds share one pause/resume
contract so behavior can never drift between them.
*/
const question = typeof node.config?.question === "string" && node.config.question.trim()
? node.config.question.trim()
: typeof node.config?.prompt === "string" && node.config.prompt.trim()
? node.config.prompt.trim()
: "This workflow is waiting for your input.";
const marker = `workflow-input:${node.id}`;
const steering = Array.isArray(live.steeringComments) ? live.steeringComments : [];
@@ -7187,7 +7199,10 @@ export class TaskExecutor {
if (staleInput === "clear") live = await this.store.getTask(nodeTask.id);
// Await-input nodes never run a session — they pause for the user.
if (cfg.awaitInput === true) {
// FNXC:WorkflowAskUser 2026-07-05-00:00: `ask-user` is the dedicated,
// discoverable node kind for this same pause; `prompt` + `config.awaitInput:
// true` remains a back-compat alias (both route to the identical runner).
if (cfg.awaitInput === true || node.kind === "ask-user") {
return this.runAwaitInputNode(node, live);
}

View File

@@ -25,6 +25,7 @@ import {
createMergeAttemptHandler,
createMergeGateHandler,
} from "./workflow-node-runners/merge-runner.js";
import { createExitGateHandler } from "./workflow-node-runners/exit-gate-runner.js";
export { createGateHandler } from "./workflow-node-runners/gate-runner.js";
export {
@@ -40,6 +41,10 @@ export {
createNotifyHandler,
type WorkflowNotifyDispatch,
} from "./workflow-node-runners/notify-runner.js";
export {
createExitGateHandler,
type WorkflowExitGateConfig,
} from "./workflow-node-runners/exit-gate-runner.js";
// FNXC:WorkflowExecution 2026-06-25-00:00: U4 (KTD-2) — the `workflow-step` seam
// was removed. Workflow quality gates run as the graph's own optional-group /
@@ -592,7 +597,9 @@ export function createDefaultNodeHandlers(
| "branch-group-promotion"
| "pr-create"
| "pr-respond"
| "pr-merge",
| "pr-merge"
| "ask-user"
| "exit-gate",
WorkflowNodeHandler
> {
const promptLike = deps?.primitives
@@ -626,6 +633,15 @@ export function createDefaultNodeHandlers(
return {
prompt: promptLike,
script: promptLike,
// FNXC:WorkflowAskUser 2026-07-05-00:00: `ask-user` is a first-class node
// kind over the SAME custom-node seam as prompt/script — it carries no
// seam config, so it always falls through to the injected custom-node
// runner (runGraphCustomNode in executor.ts), which special-cases
// `node.kind === "ask-user"` onto the existing await-input park/resume path.
"ask-user": promptLike,
// FNXC:WorkflowExitGate 2026-07-05-00:00: dedicated small runner (mirrors
// notify-runner's shape) — no legacy seam, no custom-node execution.
"exit-gate": createExitGateHandler(),
gate,
"step-review": deps?.primitives
? createPrimitiveStepReviewHandler(deps.primitives)

View File

@@ -0,0 +1,81 @@
import type { WorkflowLoopExitCondition } from "@fusion/core";
import type { WorkflowNodeHandler, WorkflowNodeResult } from "../workflow-graph-executor.js";
import type { WorkflowNodeRunner, WorkflowNodeRunnerContext } from "../workflow-node-runner.js";
/*
FNXC:WorkflowExitGate 2026-07-05-00:00:
FN-7579's `exit-gate` node lets a workflow terminate early instead of always
walking to the terminal `end` node the long way (e.g. breaking out of a
brainstorming ask-user/refine loop once the user approves). It is validated
(workflow-ir.ts) to always have a path to `end`, but it is NOT itself an `end`
node — it only routes there.
Contract: `config.condition` is optional and reuses the same shape as a `loop`
node's `exitWhen` (`WorkflowLoopExitCondition`: `output-contains` /
`output-matches`), read against `context[\`input:${condition.nodeId}\`]` — the
same context key an `ask-user` node's answer is published under — so an
exit-gate can gate directly on what the user said. Absent `condition`, the gate
is unconditional and always exits. The runner never throws on a malformed
condition; it degrades to "does not match" so a bad author config can't crash
the walk, it just falls through instead of exiting early.
Routing: the runner returns `outcome: "success"` with `value: "exit"` (match /
unconditional) or `value: "continue"` (no match). Workflow edges select on
`outcome:exit` / `outcome:continue` (or a single unconditional edge, which
matches any `success` outcome) exactly like the existing gate/step-review
outcome-edge convention.
*/
export interface WorkflowExitGateConfig {
condition?: WorkflowLoopExitCondition;
}
function resolveConditionText(
condition: WorkflowLoopExitCondition,
context: Record<string, unknown>,
): string {
const key = typeof condition.nodeId === "string" && condition.nodeId ? `input:${condition.nodeId}` : undefined;
const raw = key ? context[key] : undefined;
if (raw === undefined || raw === null) return "";
return typeof raw === "string" ? raw : String(raw);
}
function matchesExitCondition(
condition: WorkflowLoopExitCondition,
context: Record<string, unknown>,
): boolean {
const text = resolveConditionText(condition, context);
if (condition.type === "output-contains") {
return typeof condition.value === "string" && text.includes(condition.value);
}
if (condition.type === "output-matches") {
try {
return new RegExp(condition.pattern, condition.flags).test(text);
} catch {
// Malformed author-supplied regex: degrade to no-match rather than throw.
return false;
}
}
return false;
}
export class ExitGateNodeRunner implements WorkflowNodeRunner {
public readonly kind = "exit-gate" as const;
public async run(
node: Parameters<WorkflowNodeHandler>[0],
context: WorkflowNodeRunnerContext,
): Promise<WorkflowNodeResult> {
const cfg = (node.config ?? {}) as WorkflowExitGateConfig;
if (!cfg.condition) {
return { outcome: "success", value: "exit" };
}
const matched = matchesExitCondition(cfg.condition, context.context);
return { outcome: "success", value: matched ? "exit" : "continue" };
}
}
export function createExitGateHandler(): WorkflowNodeHandler {
const runner = new ExitGateNodeRunner();
return (node, context) => runner.run(node, context);
}