// Built-in flow-node kind: `human` — a human-in-the-loop approval step (a BPMN // `` with the Zeebe user-task marker). It carries a form binding // and, optionally, an assignment (assignee / candidate groups) and an I/O // mapping — the surface every nwf approval gate (answer escalation, plan review, // trial-merge decision) is built from. Contributed from this module through the // extension seam (epic #314, S3/#318) — no central union or dispatch is edited. import { escapeXml } from "../xml.js"; import { incomingOutgoing } from "../declarative.js"; import { assertIoMapping, isNonBlankString, renderIoMapping } from "../io-mapping.js"; import type { HumanIoMapping } from "../io-mapping.js"; import { registerNodeKind } from "./registry.js"; // The ioMapping shape lives in the shared `io-mapping` module (single source of // truth across node kinds); re-export it so existing `@nanobpm/workflow` // consumers keep importing `HumanIoMapping` / `HumanIoEntry` unchanged. export type { HumanIoEntry, HumanIoMapping } from "../io-mapping.js"; /** Options for {@link FlowBuilder.human}. `form` is the `zeebe:formDefinition` * form id; `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition` * (supply either or both); `io` populates a `zeebe:ioMapping`. */ export interface HumanOptions { /** The `zeebe:formDefinition` form id bound to this task. */ form: string; /** A single assignee (a static user id or a FEEL expression like * `=escalationAssignee`). */ assignee?: string; /** The candidate groups allowed to claim the task (a static group id or a FEEL * expression). */ candidateGroups?: string; /** Input/output variable mappings. */ io?: HumanIoMapping; } /** The `human` node shape (its `FlowNode` variant). */ interface HumanNode { kind: "human"; name: string; form: string; assignee?: string; candidateGroups?: string; io?: HumanIoMapping; } declare module "../types.js" { interface FlowNodeRegistry { human: HumanNode; } } declare module "../declarative.js" { interface FlowBuilder { /** * A human-in-the-loop approval step (a BPMN `` with the Zeebe * user-task marker). `opts.form` binds a `zeebe:formDefinition`; the optional * `assignee` / `candidateGroups` populate a `zeebe:assignmentDefinition` * (either or both), and `io` populates a `zeebe:ioMapping`. Resume it from a * task list / the Zeebe user-task API; the token waits durably until the task * is completed. */ human(name: K, opts: HumanOptions): FlowBuilder; } } /** A present, non-empty, non-whitespace-only string — the shape every human() * id / assignment field must have. A blank value would emit a meaningless BPMN * attribute (an empty formId, assignee, or candidateGroups) that fails or * misbehaves at deploy/run time, so we fail fast at build. Shared with the * ioMapping validator via `io-mapping`. */ function renderAssignment(node: HumanNode): string { if (node.assignee === undefined && node.candidateGroups === undefined) return ""; const attrs = [ node.assignee !== undefined ? ` assignee="${escapeXml(node.assignee)}"` : "", node.candidateGroups !== undefined ? ` candidateGroups="${escapeXml(node.candidateGroups)}"` : "", ].join(""); return ` \n`; } function renderUserTask(node: HumanNode, inc: string[], outg: string[]): string { const id = node.name; const ext = ` \n` + ` \n` + ` \n` + renderAssignment(node) + renderIoMapping(node.io) + ` `; return ( ` \n` + ext + "\n" + incomingOutgoing(inc, outg) + ` ` ); } registerNodeKind("human", { build: (api) => (name: string, opts: HumanOptions) => { api.claim(name); if (opts === null || typeof opts !== "object") { throw new Error(`human("${name}") needs an options object { form, … }`); } if (!isNonBlankString(opts.form)) { throw new Error(`human("${name}") needs a non-empty { form } (the zeebe:formDefinition form id)`); } if (opts.assignee !== undefined && !isNonBlankString(opts.assignee)) { throw new Error(`human("${name}") { assignee } must be a non-empty string`); } if (opts.candidateGroups !== undefined && !isNonBlankString(opts.candidateGroups)) { throw new Error(`human("${name}") { candidateGroups } must be a non-empty string`); } assertIoMapping(`human("${name}")`, opts.io); api.out.push({ kind: "human", name, form: opts.form, assignee: opts.assignee, candidateGroups: opts.candidateGroups, io: opts.io, }); return api.self(); }, emit: (node, incoming, _loop, api) => { api.addNode({ id: node.name, render: (inc, outg) => renderUserTask(node, inc, outg) }); api.connect(incoming, node.name); return [api.newEdge(node.name)]; }, });