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:
7
.changeset/fn-7579-ask-user-exit-gate-nodes.md
Normal file
7
.changeset/fn-7579-ask-user-exit-gate-nodes.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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)?/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" />,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user