@nanobpm/nano-workforce 0.201.0 → 0.201.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +153 -0
- package/app/agentActivationLedger.test.ts +109 -0
- package/app/agentActivationLedger.ts +144 -0
- package/app/agentActivationsPoll.test.ts +180 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/agentSlaEscalation.test.ts +137 -0
- package/app/agentSlaEscalation.ts +169 -0
- package/app/agentSlaMarkerGuard.test.ts +172 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +251 -0
- package/app/deliveryGraph.ts +334 -1
- package/app/deliveryGraphCompiler.test.ts +1345 -30
- package/app/deliveryGraphCompiler.ts +882 -240
- package/app/deliveryGraphDeploy.test.ts +472 -3
- package/app/deliveryGraphRun.ts +2 -2
- package/app/deliveryHuman.test.ts +59 -5
- package/app/deliveryHuman.ts +115 -3
- package/app/deliveryRunner.test.ts +3 -3
- package/app/mergeLoopBehaviour.test.ts +46 -1
- package/app/redactText.ts +88 -0
- package/app/service.ts +110 -2
- package/db/migrations/119_agent_job_activations.sql +50 -0
- package/e2e/agent-sla-boundary.e2e.ts +51 -8
- package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
- package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
- package/e2e/delivery-graph.e2e.ts +11 -7
- package/package.json +1 -1
- package/resources/forms/delivery-escalation.form +41 -0
- package/resources/forms/delivery-human-generic.form +4 -3
- package/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/implement-cell.bpmn +4 -1
- package/resources/processes/merge-cell.bpmn +4 -1
- package/resources/processes/merge-loop.bpmn +7 -0
- package/resources/processes/plan-fanout.bpmn +5 -0
- package/resources/processes/retro.bpmn +5 -2
- package/test/derivation-parity/flows.ts +5 -3
- package/workers/conformance-record/worker.ts +15 -1
- package/workers/persist-escalation/worker.ts +22 -3
- package/workers/record-feature-escalation/worker.ts +28 -12
- package/workers/record-trial-merge/worker.ts +16 -1
|
@@ -38,22 +38,35 @@ import { TRANSCRIPT_URL_BASE_VAR, TRANSCRIPT_URL_VAR } from "./agentic/transcrip
|
|
|
38
38
|
import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, isConvergeTarget, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
39
39
|
import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
|
|
40
40
|
import {
|
|
41
|
+
AGENT_RESULT_LOCAL_VARS,
|
|
41
42
|
analyzeExclusiveTopology,
|
|
43
|
+
CONNECTOR_RESULT_LOCAL_VARS,
|
|
42
44
|
canonicalJson,
|
|
43
45
|
type DeliveryGraphError,
|
|
44
46
|
deliveryNodeFacts,
|
|
47
|
+
ESCALATION_DECISION_RETRY,
|
|
48
|
+
ESCALATION_DECISION_VAR,
|
|
49
|
+
ESCALATION_LOCAL_VARS,
|
|
50
|
+
ESCALATION_NOTE_VAR,
|
|
51
|
+
HUMAN_RESULT_LOCAL_VARS,
|
|
45
52
|
hasXmlInvalidChars,
|
|
46
53
|
redactConnectorValue,
|
|
47
54
|
resolveDeliveryFrom,
|
|
48
55
|
stripXmlInvalidChars,
|
|
49
56
|
validateDeliveryGraph,
|
|
50
57
|
} from "./deliveryGraph.ts";
|
|
51
|
-
import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
|
|
58
|
+
import { DELIVERY_CONTRACT_TWIN_SUFFIX, DELIVERY_ESCALATION_TWIN_SUFFIX, DELIVERY_HUMAN_ELEMENT, DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX, ESCALATION_FORM, GENERIC_HUMAN_FORM, resolveHumanForm } from "./deliveryHuman.ts";
|
|
52
59
|
import { layoutBpmnOffThread } from "./layoutOffThread.ts";
|
|
53
|
-
import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll,
|
|
60
|
+
import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll, redactString } from "./readiness.ts";
|
|
61
|
+
// `redactFreeText` now lives in the low-level `redactText.ts` helper (both this compiler and
|
|
62
|
+
// `deliveryHuman.ts` import it there) to break the former `compiler ⇄ deliveryHuman` import cycle
|
|
63
|
+
// (PR #863 review). Re-exported here so existing `./deliveryGraphCompiler.ts` consumers keep resolving it.
|
|
64
|
+
import { redactFreeText } from "./redactText.ts";
|
|
54
65
|
import { AGENT_TASK_NS } from "./repoEnvelope.ts";
|
|
55
66
|
import { isoDuration } from "./reviewWait.ts";
|
|
56
67
|
|
|
68
|
+
export { redactFreeText };
|
|
69
|
+
|
|
57
70
|
/** A display-safe rendering of a `wait` probe's target for user-visible BPMN name/documentation
|
|
58
71
|
* (issue #778 review): a `command` target is an arbitrary shell snippet that can embed a secret, so it
|
|
59
72
|
* is never surfaced — only a fixed placeholder; an `http` target can carry a credential in its
|
|
@@ -161,8 +174,12 @@ function humanTaskElement(element: string): string {
|
|
|
161
174
|
/** The BPMN element id a service node's bounded-timeout escalation user task carries — same
|
|
162
175
|
* human-completable convention as a human node, so a stalled `agent`/`wait`/`connector` escalates onto
|
|
163
176
|
* the Tasks inbox and is answerable by a human OR an agent (ADR 0046). */
|
|
164
|
-
function escalationTaskElement(element: string): string {
|
|
165
|
-
|
|
177
|
+
function escalationTaskElement(element: string, kind?: DeliveryNode["kind"]): string {
|
|
178
|
+
// A `wait` gate's escalation has no retry semantics, so it renders the select-less generic form and
|
|
179
|
+
// stamps the `__wait` kind marker into its id — letting `escalationFormId` DERIVE that form from the
|
|
180
|
+
// id rather than inferring the retry-capable form from the shared `__esc` suffix (PR #863 review).
|
|
181
|
+
const suffix = kind === "wait" ? DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX : DELIVERY_ESCALATION_TWIN_SUFFIX;
|
|
182
|
+
return `${DELIVERY_HUMAN_ELEMENT}__${element}${suffix}`;
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
/** The BPMN element id an `agent` node's PRODUCER-CONTRACT escalation user task carries (issue #731) —
|
|
@@ -172,7 +189,35 @@ function escalationTaskElement(element: string): string {
|
|
|
172
189
|
* a producer that finishes without doing its job escalates AT that node and is answerable by a human
|
|
173
190
|
* OR an agent. */
|
|
174
191
|
function contractEscalationTaskElement(element: string): string {
|
|
175
|
-
return `${DELIVERY_HUMAN_ELEMENT}__${element}
|
|
192
|
+
return `${DELIVERY_HUMAN_ELEMENT}__${element}${DELIVERY_CONTRACT_TWIN_SUFFIX}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ESCALATION_DECISION_VAR / ESCALATION_DECISION_RETRY / ESCALATION_LOCAL_VARS / AGENT_RESULT_LOCAL_VARS /
|
|
196
|
+
// CONNECTOR_RESULT_LOCAL_VARS are imported from ./deliveryGraph.ts (their canonical home — the
|
|
197
|
+
// validator's reservedDeliveryFactNames derives from them there without an import cycle).
|
|
198
|
+
// ESCALATION_LOCAL_VARS is [ESCALATION_DECISION_VAR,"value",ESCALATION_NOTE_VAR]; AGENT_RESULT_LOCAL_VARS is the
|
|
199
|
+
// canonical node-local agent result contract (every resources/prompts/*.md output field);
|
|
200
|
+
// CONNECTOR_RESULT_LOCAL_VARS the connector's fixed result metadata. All three are declared node-local
|
|
201
|
+
// on the subProcess and cleared on retry so a node's report never leaks to the shared root.
|
|
202
|
+
|
|
203
|
+
/** The node-local boolean the preflight input mapping binds (see {@link nodeInputsPreflightFeel}). */
|
|
204
|
+
const NODE_INPUTS_PREFLIGHT_VAR = "nodeInputsPresent";
|
|
205
|
+
|
|
206
|
+
/** FEEL for the per-node preflight guard: `true` when the runner-seeded `nodeInputs.<el>` config is
|
|
207
|
+
* present, else a FEEL `assert` failure — an input-mapping incident on the node whose message names the
|
|
208
|
+
* cause and the fix, instead of a node that runs with null config and escalates with no explanation. */
|
|
209
|
+
export function nodeInputsPreflightFeel(el: string): string {
|
|
210
|
+
const present = `is defined(nodeInputs) and nodeInputs != null and is defined(nodeInputs.${el}) and nodeInputs.${el} != null`;
|
|
211
|
+
const cause =
|
|
212
|
+
`delivery-graph node ${el}: its runner-seeded config nodeInputs.${el} is missing from the process ` +
|
|
213
|
+
"instance (lost root variables?). Restore the instance's root variables (nodeInputs), then RE-ENTER " +
|
|
214
|
+
"the node's sub-process from OUTSIDE (relaunch/re-enter the node) so its subProcess input mappings " +
|
|
215
|
+
"re-evaluate against the restored config. Do NOT only resolve this incident, and do NOT use this " +
|
|
216
|
+
"node's in-subprocess \"Retry this step\" loop: resolving re-evaluates just this leaf's inputs, and " +
|
|
217
|
+
"the retry loop goes straight back to the inner service task — neither re-runs the subProcess entry " +
|
|
218
|
+
"mappings, so the subProcess-seeded config (prompt/appendPrompt/nodeTimeout, connector " +
|
|
219
|
+
"target/payload/dedupeKey) stays null and the job would run unconfigured.";
|
|
220
|
+
return `=assert(true, ${present}, ${feelStr(cause)})`;
|
|
176
221
|
}
|
|
177
222
|
|
|
178
223
|
/** A distinctive infix stamped into the compiler-generated resume-validity flag variable ({@link
|
|
@@ -1100,82 +1145,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
1100
1145
|
* in `deliveryGraph.ts`) so the DISPLAY path here and `validateDeliveryGraph`'s reject/error path share
|
|
1101
1146
|
* ONE redactor — no drift surface (issue #778 review). */
|
|
1102
1147
|
|
|
1103
|
-
/** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
|
|
1104
|
-
* `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
|
|
1105
|
-
* truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
|
|
1106
|
-
* surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
|
|
1107
|
-
* `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
|
|
1108
|
-
* punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
|
|
1109
|
-
* a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
|
|
1110
|
-
* the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
|
|
1111
|
-
* reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
|
|
1112
|
-
* and total. */
|
|
1113
|
-
export function redactFreeText(value: string): string {
|
|
1114
|
-
// Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
|
|
1115
|
-
// string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
|
|
1116
|
-
// the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
|
|
1117
|
-
// `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
|
|
1118
|
-
// connector/probe strip-before-classify fix).
|
|
1119
|
-
const cleaned = stripXmlInvalidChars(value);
|
|
1120
|
-
// Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
|
|
1121
|
-
// (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
|
|
1122
|
-
// spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
|
|
1123
|
-
// collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
|
|
1124
|
-
// its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
|
|
1125
|
-
// CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
|
|
1126
|
-
// credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
|
|
1127
|
-
// (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
|
|
1128
|
-
// stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
|
|
1129
|
-
// span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
|
|
1130
|
-
// SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
|
|
1131
|
-
// visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
|
|
1132
|
-
// marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
|
|
1133
|
-
// the query/fragment belt below.
|
|
1134
|
-
const credStripped = redactEmbeddedCredential(cleaned);
|
|
1135
|
-
// Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
|
|
1136
|
-
// `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
|
|
1137
|
-
// each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
|
|
1138
|
-
// CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
|
|
1139
|
-
// past the break is indistinguishable from a split value, so we never keep the far side (issue #778
|
|
1140
|
-
// review — thread deliveryGraphCompiler.ts:1115).
|
|
1141
|
-
const belted = redactQueryFragmentSpans(credStripped);
|
|
1142
|
-
return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
/** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
|
|
1146
|
-
* whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
|
|
1147
|
-
* cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
|
|
1148
|
-
* already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
|
|
1149
|
-
* belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
|
|
1150
|
-
* `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
|
|
1151
|
-
* indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
|
|
1152
|
-
* prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
|
|
1153
|
-
* single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
|
|
1154
|
-
* regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
|
|
1155
|
-
* #778 review). Deterministic and total. */
|
|
1156
|
-
function redactQueryFragmentSpans(text: string): string {
|
|
1157
|
-
let out = "";
|
|
1158
|
-
let i = 0;
|
|
1159
|
-
for (;;) {
|
|
1160
|
-
const start = text.indexOf("//", i);
|
|
1161
|
-
if (start < 0) return out + text.slice(i);
|
|
1162
|
-
out += text.slice(i, start);
|
|
1163
|
-
let end = start;
|
|
1164
|
-
while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
|
|
1165
|
-
const span = text.slice(start, end);
|
|
1166
|
-
const qMark = span.indexOf("?");
|
|
1167
|
-
const hMark = span.indexOf("#");
|
|
1168
|
-
const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
|
|
1169
|
-
// CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
|
|
1170
|
-
// (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
|
|
1171
|
-
// INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
|
|
1172
|
-
// prompt still reaches the runtime job input unmodified (issue #778 review — thread
|
|
1173
|
-
// deliveryGraphCompiler.ts:1115).
|
|
1174
|
-
out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
|
|
1175
|
-
i = end;
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
|
|
1179
1148
|
/** A human-readable label for a connector node's `target`. The converge-enrollment vocabulary
|
|
1180
1149
|
* (`convergeTargets.ts`) maps to intent-revealing phrases; any other (forward-declared) target is shown
|
|
1181
1150
|
* through {@link redactConnectorValue} — an opaque identifier (`slack:#releases`) survives unchanged, but
|
|
@@ -1824,6 +1793,75 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1824
1793
|
return assertNever(node, "ioMappingLines");
|
|
1825
1794
|
}
|
|
1826
1795
|
|
|
1796
|
+
// Node-local result scope (see AGENT_RESULT_LOCAL_VARS / ESCALATION_LOCAL_VARS): declare the variables a
|
|
1797
|
+
// job completion / escalation completion writes as `null` on THIS subProcess, so Nano's nearest-scope
|
|
1798
|
+
// propagation lands them here instead of the shared root. BOTH service-node kinds (agent AND connector)
|
|
1799
|
+
// also localise each declared emit's source variable (an agent/connector fact's source is the fact's
|
|
1800
|
+
// own name — see factSourceVar), so a sibling's same-named emit can never satisfy this node's contract
|
|
1801
|
+
// gate or publish through this node's `<el>_<fact>` output. A connector ALSO returns fixed result
|
|
1802
|
+
// metadata (`connectorOutcome`/`connectorDedupeKey`/`connectorDetail`); without localising those too,
|
|
1803
|
+
// parallel connectors overwrite each other's root-scoped result (Copilot review #863, thread
|
|
1804
|
+
// r4180856788) exactly as parallel agents once overwrote one shared root status/pr.
|
|
1805
|
+
// A `DeliveryNodeConnector` permits `emits` too, so WITHOUT this a timed-out connector could publish a
|
|
1806
|
+
// parallel sibling connector's root-scoped result through its own output mapping (Copilot review #863,
|
|
1807
|
+
// thread r4179717614).
|
|
1808
|
+
// A `human` node has no worker result, but its user task captures the operator's answer into fixed,
|
|
1809
|
+
// non-node-unique scratch (`humanEmitValue`/`humanEmitArtifact`/`humanOutcome`/`humanNote`) by SELECTING
|
|
1810
|
+
// from fixed form controls (`value`/`resolvedArtifact`/`note`, plus a bespoke form's fact-named control).
|
|
1811
|
+
// Left at the shared ROOT these cross-publish between parallel human nodes: a human that completes while
|
|
1812
|
+
// a sibling times out WITHOUT producing a value would have the sibling read its root `humanEmitValue`
|
|
1813
|
+
// and publish it as the sibling's own `<el>_<fact>` — the complete-one/timeout-the-other leak (Copilot
|
|
1814
|
+
// review #863, thread r4199949849). So a human subProcess ALSO localises its scratch + form controls
|
|
1815
|
+
// (HUMAN_RESULT_LOCAL_VARS) and its explicit fact-named control (the declared emit's own name, read first
|
|
1816
|
+
// by a bespoke single-emit form), fail-closing a value-less timeout to its OWN null instead of a
|
|
1817
|
+
// sibling's.
|
|
1818
|
+
// A `wait` node likewise has no worker result, but its SLA-timeout escalation parks on the SAME generic
|
|
1819
|
+
// form, whose completion writes the escalation controls (ESCALATION_LOCAL_VARS = decision/value/
|
|
1820
|
+
// escalationNote). On the escalate path the interrupting boundary timer CANCELS the probeLoop before its
|
|
1821
|
+
// output mapping runs, so the `_lastAttempt` probe result and any value-resume materialise the emit
|
|
1822
|
+
// SOURCE (`detail`/`resolvedArtifact`/`mergedSha`/`prCount` — factSourceVar) with NOTHING declaring them
|
|
1823
|
+
// in the wait subProcess. Left at the shared ROOT those cross-publish between parallel timed-out waits:
|
|
1824
|
+
// a value-less completion on one wait reads a sibling's root `value`/emit and resumes from the sibling's
|
|
1825
|
+
// answer (Copilot review #863, thread r4200608648). So a wait subProcess ALSO localises its escalation
|
|
1826
|
+
// controls + each declared emit source. None of these is read by a wait gateway (the `ready?` splits read
|
|
1827
|
+
// only `ready`, which stays un-seeded), so the null-seed cannot shadow a gateway (the multi-instance
|
|
1828
|
+
// `=null`-shadow gotcha). A wait has no retry escape, so there is no retry-reset to clear them.
|
|
1829
|
+
if (node.kind === "agent" || node.kind === "connector" || node.kind === "human" || node.kind === "wait") {
|
|
1830
|
+
const locals = new Set<string>();
|
|
1831
|
+
if (node.kind === "human") {
|
|
1832
|
+
for (const v of HUMAN_RESULT_LOCAL_VARS) locals.add(v);
|
|
1833
|
+
// A bespoke single-emit human form captures the value under the FACT'S OWN NAME (humanBodyLines'
|
|
1834
|
+
// `selectExpr` reads `<factName>` before the canonical control), so that control must be node-local
|
|
1835
|
+
// too — otherwise a blank completion reads a sibling's root value. factSourceVar maps a human emit to
|
|
1836
|
+
// the fixed `humanEmitValue`/`humanEmitArtifact` (already in the set), so seed the fact name directly.
|
|
1837
|
+
for (const fact of normaliseEmits(node)) locals.add(fact.name);
|
|
1838
|
+
} else {
|
|
1839
|
+
// agent / connector / wait: the escalation user task completes with a form's controls, and each
|
|
1840
|
+
// declared emit's SOURCE var (factSourceVar) must fail-close to this node's own null.
|
|
1841
|
+
for (const v of ESCALATION_LOCAL_VARS) locals.add(v);
|
|
1842
|
+
if (node.kind === "agent") {
|
|
1843
|
+
for (const v of AGENT_RESULT_LOCAL_VARS) locals.add(v);
|
|
1844
|
+
} else if (node.kind === "connector") {
|
|
1845
|
+
for (const v of CONNECTOR_RESULT_LOCAL_VARS) locals.add(v);
|
|
1846
|
+
} else {
|
|
1847
|
+
// `wait`: its SLA-timeout escalation renders the GENERIC human form (`delivery-human-generic`),
|
|
1848
|
+
// whose controls are `value` + `note` — NOT the retry-capable `ESCALATION_FORM`'s
|
|
1849
|
+
// `decision`/`value`/`escalationNote` (a wait has no retryElement, so `escalationTaskLines` picks
|
|
1850
|
+
// the generic form). `value` is already in ESCALATION_LOCAL_VARS, but the generic form's `note`
|
|
1851
|
+
// control is NOT — so completing a timed-out wait with an operator note would propagate `note` to
|
|
1852
|
+
// the shared ROOT, letting parallel waits overwrite one another's note and exposing a
|
|
1853
|
+
// wait-specific note to later jobs (Copilot review #863, thread r4200916073). Seed it node-local.
|
|
1854
|
+
locals.add("note");
|
|
1855
|
+
}
|
|
1856
|
+
// (a `wait` has no self-reported worker result metadata — only its escalation controls + emit sources)
|
|
1857
|
+
for (const fact of normaliseEmits(node)) locals.add(factSourceVar(node.kind, fact));
|
|
1858
|
+
}
|
|
1859
|
+
const taken = new Set(inputs.map((i) => i.target));
|
|
1860
|
+
for (const v of locals) {
|
|
1861
|
+
if (!taken.has(v)) inputs.push({ source: "=null", target: v });
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1827
1865
|
// Late-binding: a deterministic FEEL list literal of the upstream producers' emitted facts, keyed
|
|
1828
1866
|
// exactly as the edge references them (`<producerNode>.<fact>`), read from the flat parent variable
|
|
1829
1867
|
// each producer publishes. Guarded so an as-yet-unobserved fact threads as null, not a FEEL error.
|
|
@@ -1836,8 +1874,24 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1836
1874
|
}
|
|
1837
1875
|
|
|
1838
1876
|
// Outputs: publish each declared emit into `<element>_<fact>` for a downstream consumer to bind.
|
|
1877
|
+
// A `human` node's non-artifact emits ALL share the one captured `humanEmitValue` (the generic/publish
|
|
1878
|
+
// forms have a single value field — see humanBodyLines). Publishing that one value into SEVERAL
|
|
1879
|
+
// distinct facts would corrupt them (PR #863 Copilot Low, thread r4182488264). validateDeliveryGraph
|
|
1880
|
+
// now REJECTS every ≥2-emit human node (`human-unroutable-emits`, threads
|
|
1881
|
+
// deliveryGraphCompiler.ts:1859 / :1927) — no static form can capture several emits (an explicit
|
|
1882
|
+
// `human.formKey` is no exception: this ioMapping reads only the fixed value/resolvedArtifact/note
|
|
1883
|
+
// controls, never a bespoke form's per-fact fields), so that whole class is refused at authoring time
|
|
1884
|
+
// rather than silently publishing null. This discard stays as residual defence for a ≥2-emit node that
|
|
1885
|
+
// somehow reaches compile despite the validator: only a SINGLE non-artifact human emit is sourced from
|
|
1886
|
+
// `humanEmitValue`; with two or more, every non-artifact emit publishes null. Artifact emits are
|
|
1887
|
+
// unaffected (they read the distinct `humanEmitArtifact`).
|
|
1888
|
+
const humanNonArtifactEmits =
|
|
1889
|
+
node.kind === "human" ? normaliseEmits(node).filter((f) => f.type !== "artifact") : [];
|
|
1890
|
+
const humanValueEmit = humanNonArtifactEmits.length === 1 ? humanNonArtifactEmits[0] : undefined;
|
|
1839
1891
|
for (const fact of normaliseEmits(node)) {
|
|
1840
|
-
|
|
1892
|
+
const discardHumanValue =
|
|
1893
|
+
node.kind === "human" && fact.type !== "artifact" && humanValueEmit === undefined;
|
|
1894
|
+
outputs.push({ source: discardHumanValue ? "=null" : guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
|
|
1841
1895
|
}
|
|
1842
1896
|
|
|
1843
1897
|
// Stage 0 transcript correlation (#543): propagate the completing worker's `transcriptUrl` (built
|
|
@@ -1870,38 +1924,71 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
|
|
|
1870
1924
|
// self-reported `status` AND a non-null value for every declared emit a downstream consumer binds
|
|
1871
1925
|
// as a required data dependency. A broken producer (returns `in_progress`, or omits a required
|
|
1872
1926
|
// emit) escalates AT this node instead of threading an incomplete result onward.
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
// uses — the facts a downstream node binds, which a null (lost) result poisons (#872).
|
|
1876
|
-
const timeoutResume = { kind: node.kind, emits: contractGate.requiredEmits };
|
|
1877
|
-
// The source var of EVERY declared emit (required + non-required), so the resume-valid flag on
|
|
1878
|
-
// this node's escalations dodges all of them — not only the resumed required emit (PR #876 review).
|
|
1879
|
-
const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
|
|
1927
|
+
const nodeEmits = normaliseEmits(node);
|
|
1928
|
+
const contractGate = { requiredEmits: nodeEmits.filter((f) => requiredEmits.has(f.name)), emits: nodeEmits };
|
|
1880
1929
|
// The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
|
|
1881
1930
|
// routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
|
|
1882
1931
|
// be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
|
|
1883
1932
|
// timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
|
|
1884
1933
|
// `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
|
|
1885
|
-
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName,
|
|
1934
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, "agent", nodeEmits);
|
|
1886
1935
|
}
|
|
1887
1936
|
case "connector": {
|
|
1888
1937
|
// TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
|
|
1889
1938
|
// worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
|
|
1890
1939
|
// `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
|
|
1891
1940
|
// review — thread deliveryGraphCompiler.ts:1741).
|
|
1892
|
-
// A connector
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
const
|
|
1899
|
-
|
|
1941
|
+
// A connector declares `emits` (its emit source is the fact's own name) but has NO
|
|
1942
|
+
// producer-contract gate, so pass them explicitly as `nodeEmits` — otherwise the timeout
|
|
1943
|
+
// escalation could not resume a connector's emit on Continue, nor clear it on Retry (#863).
|
|
1944
|
+
// The resume/validation path (Continue) uses only the REQUIRED emits a downstream node binds —
|
|
1945
|
+
// the facts a null (lost) result poisons (#872/#876) — while `nodeEmits` (all declared emits)
|
|
1946
|
+
// drive the Retry clear and the resume-valid flag's collision-avoidance targets.
|
|
1947
|
+
const connectorEmits = normaliseEmits(node);
|
|
1948
|
+
const connectorRequired = connectorEmits.filter((f) => requiredEmits.has(f.name));
|
|
1949
|
+
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName, "connector", connectorEmits, connectorRequired);
|
|
1900
1950
|
}
|
|
1901
1951
|
case "wait":
|
|
1902
1952
|
return waitBodyLines(el, node, displayName);
|
|
1903
|
-
case "human":
|
|
1904
|
-
|
|
1953
|
+
case "human": {
|
|
1954
|
+
// Select the human node's form by DERIVING it from the canonical resolver (resolveHumanForm),
|
|
1955
|
+
// never hardcoding the generic form: a 0-emit node uses the acknowledgement form, a single-artifact
|
|
1956
|
+
// node uses the manual-publish form (which carries the `resolvedArtifact` control the generic form
|
|
1957
|
+
// lacks, so the artifact is actually captured instead of published null), and a single-value node
|
|
1958
|
+
// uses the generic typed-emit form. A ≥2-emit node resolves to the agent-router (formKey null) and
|
|
1959
|
+
// is REJECTED upstream by validateDeliveryGraph (`human-unroutable-emits` — a bespoke formKey is no
|
|
1960
|
+
// rescue for ≥2 emits, since one form value cannot satisfy several typed facts), so the
|
|
1961
|
+
// `?? GENERIC_HUMAN_FORM` fallback here is unreachable defensive cover (PR #863 threads
|
|
1962
|
+
// deliveryGraphCompiler.ts:1859 / :1927).
|
|
1963
|
+
// For a SINGLE emit the node MAY carry an EXPLICIT bespoke form whose field is named after the
|
|
1964
|
+
// emitted fact (the canonical binding contract `bindHumanEmits` reads the fact's own key first,
|
|
1965
|
+
// then the generic `value`/`resolvedArtifact` capture keys — deliveryHuman.ts) — so thread the one
|
|
1966
|
+
// emit through so the userTask ioMapping reads that fact-named field too. Without it a valid
|
|
1967
|
+
// `{ approval: "yes" }` custom form for an `approval` emit renders fine but publishes NULL (the
|
|
1968
|
+
// ioMapping only read the fixed `value`/`resolvedArtifact` controls) — PR #863 review, thread
|
|
1969
|
+
// deliveryGraphCompiler.ts:1929. SCOPE the fact-NAMED read to `source === "explicit"`: the built-in
|
|
1970
|
+
// generic/publish forms capture under the FIXED `value`/`resolvedArtifact` controls, and a fact
|
|
1971
|
+
// happening to be named after one of those built-in forms' OTHER fields (e.g. a single emit named
|
|
1972
|
+
// `note`) must not make the mapping read that unrelated field — only a bespoke form keys its control
|
|
1973
|
+
// on the fact.
|
|
1974
|
+
// TYPE COERCION is a WIDER contract than the fact-named read: the SAME class of bug (a captured
|
|
1975
|
+
// TEXT value published verbatim for a typed fact) also bites the GENERIC single-value form — a lone
|
|
1976
|
+
// `boolean`/`number` emit answered through the generic textfield would publish the string
|
|
1977
|
+
// `"true"`/`"1"` that a downstream guarded split (`= true`/`= 1`) never matches. So coerce by the
|
|
1978
|
+
// one declared emit's type for EVERY form source (explicit AND built-in), while preferring the
|
|
1979
|
+
// fact-named field ONLY for an explicit bespoke form (PR #863 thread r4189815989).
|
|
1980
|
+
const humanForm = resolveHumanForm(node);
|
|
1981
|
+
const singleEmit = humanForm.emits.length === 1 ? humanForm.emits[0] : undefined;
|
|
1982
|
+
const preferFactName = humanForm.source === "explicit";
|
|
1983
|
+
// A human node has no worker self-report to gate on, but its single emit CAN be a downstream-REQUIRED
|
|
1984
|
+
// fact — and invalid typed input coerces to null (fail-closed on the value, coerceFactValueFeel), so
|
|
1985
|
+
// WITHOUT a completion gate the node ends and the unconditional edge activates the consumer with a
|
|
1986
|
+
// null required fact (PR #863 review, thread r4198662345). Thread whether that single emit is
|
|
1987
|
+
// required so humanBodyLines grows a fail-closed completion gate over it (re-enter on an invalid
|
|
1988
|
+
// value) instead of publishing null onward.
|
|
1989
|
+
const requiredEmit = singleEmit !== undefined && requiredEmits.has(singleEmit.name);
|
|
1990
|
+
return humanBodyLines(el, displayName, humanForm.formKey ?? GENERIC_HUMAN_FORM, singleEmit, preferFactName, requiredEmit);
|
|
1991
|
+
}
|
|
1905
1992
|
default:
|
|
1906
1993
|
return assertNever(node, "innerBodyLines");
|
|
1907
1994
|
}
|
|
@@ -1946,11 +2033,12 @@ function agentContractProceedCondition(requiredEmits: readonly DeliveryFact[]):
|
|
|
1946
2033
|
* so the human/agent unsticking it sees WHY it parked — the node, its job type, the actual reported
|
|
1947
2034
|
* status, and, per required emit, whether it arrived. Turns the instance-10746 failure (a silent null
|
|
1948
2035
|
* thread + two mis-attributed CONSUMER incidents) into one correctly-attributed PRODUCER escalation. */
|
|
1949
|
-
function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[]): string {
|
|
2036
|
+
function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[], nodeEmits: readonly DeliveryFact[]): string {
|
|
1950
2037
|
const statuses = AGENT_TERMINAL_SUCCESS_STATUSES.join("/");
|
|
1951
2038
|
const head = feelStr(
|
|
1952
|
-
`Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — a producer
|
|
1953
|
-
`
|
|
2039
|
+
`Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — when a producer ` +
|
|
2040
|
+
`reports a status it must be a terminal-success status (${statuses}; an absent/null status is accepted), ` +
|
|
2041
|
+
`and it must populate every emit a downstream node requires ` +
|
|
1954
2042
|
"before its result routes onward. Reported status=",
|
|
1955
2043
|
);
|
|
1956
2044
|
let feel = `=${head} + (if (is defined(status) and status != null) then string(status) else "(none)") + "."`;
|
|
@@ -1958,6 +2046,18 @@ function agentContractContextFeel(nodeId: string, descriptor: string, requiredEm
|
|
|
1958
2046
|
const present = `(is defined(${f.name}) and ${f.name} != null)`;
|
|
1959
2047
|
feel += ` + " Required emit '${f.name}': " + (if ${present} then "present" else "MISSING (null)") + "."`;
|
|
1960
2048
|
}
|
|
2049
|
+
// The node's OWN agent report (node-local since the result vars are declared on the subProcess): what
|
|
2050
|
+
// the agent said it did / why it stopped, its question for the operator, and the transcript link — the
|
|
2051
|
+
// actionable content the bare status line above never carried (171774's three report-less escalations).
|
|
2052
|
+
const text = (v: string): string => `(is defined(${v}) and ${v} != null and string(${v}) != "")`;
|
|
2053
|
+
// Each fragment reads as its own sentence on the plain-text "Decision context" (terminal period added
|
|
2054
|
+
// unless the agent's text already ends in punctuation).
|
|
2055
|
+
const sentence = (v: string): string => `string(${v}) + (if matches(string(${v}), "[.!?]\\s*$") then "" else ".")`;
|
|
2056
|
+
feel += ` + (if ${text("summary")} then " Agent report: " + ${sentence("summary")} else " Agent report: (the agent returned no summary).")`;
|
|
2057
|
+
feel += ` + (if ${text("question")} and (not(${text("summary")}) or string(question) != string(summary)) then " Agent question: " + ${sentence("question")} else "")`;
|
|
2058
|
+
feel += ` + (if ${text("error")} then " Error: " + ${sentence("error")} else "")`;
|
|
2059
|
+
feel += ` + (if ${text("transcriptUrl")} then " Transcript: " + string(transcriptUrl) else "")`;
|
|
2060
|
+
feel += ` + ${feelStr(escalationResolutionHint(nodeEmits, requiredEmits, true))}`;
|
|
1961
2061
|
return feel;
|
|
1962
2062
|
}
|
|
1963
2063
|
|
|
@@ -1979,51 +2079,95 @@ function serviceBodyLines(
|
|
|
1979
2079
|
taskDefAttr: string,
|
|
1980
2080
|
taskProps: readonly string[],
|
|
1981
2081
|
descriptor: string,
|
|
1982
|
-
contractGate?: { requiredEmits: readonly DeliveryFact[] },
|
|
2082
|
+
contractGate?: { requiredEmits: readonly DeliveryFact[]; emits: readonly DeliveryFact[] },
|
|
1983
2083
|
taskHeaders: readonly string[] = [],
|
|
1984
2084
|
taskName: string = nodeId,
|
|
1985
|
-
// The node's
|
|
1986
|
-
//
|
|
1987
|
-
//
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
//
|
|
1994
|
-
//
|
|
1995
|
-
//
|
|
1996
|
-
//
|
|
1997
|
-
|
|
1998
|
-
reservedFlagTargets: readonly string[] = [],
|
|
2085
|
+
// The node's kind (`agent`/`connector`) and its OWN declared emits. A connector passes
|
|
2086
|
+
// `contractGate = undefined` (no self-reported status contract) yet still declares `emits`, so the
|
|
2087
|
+
// escalation resume / retry-clear below key off the node's OWN emits, not the agent-only gate — and
|
|
2088
|
+
// map them through `factSourceVar(kind, …)` (issue #863 review — thread "Connector emits are lost
|
|
2089
|
+
// during timeout escalation recovery").
|
|
2090
|
+
kind: "agent" | "connector" = "agent",
|
|
2091
|
+
nodeEmits: readonly DeliveryFact[] = contractGate?.emits ?? [],
|
|
2092
|
+
// The node's REQUIRED emits (the facts a downstream node binds as a data dependency). The #863 Retry
|
|
2093
|
+
// path clears ALL `nodeEmits`; the #876 Continue/resume path is gated through fail-closed validation
|
|
2094
|
+
// on exactly these REQUIRED emits — a null/invalid operator value re-parks instead of threading null
|
|
2095
|
+
// onto a consumed fact (issue #872). Defaults to the contract gate's required emits (an agent); a
|
|
2096
|
+
// connector passes its own required subset explicitly (it has no gate to derive from).
|
|
2097
|
+
resumeEmits: readonly DeliveryFact[] = contractGate?.requiredEmits ?? [],
|
|
1999
2098
|
): string[] {
|
|
2000
2099
|
const esc = escalationTaskElement(el);
|
|
2100
|
+
const isAgent = contractGate !== undefined;
|
|
2101
|
+
// Gateway decisions are computed INSIDE the node's scope and published as a node-UNIQUE root boolean,
|
|
2102
|
+
// because Nano evaluates exclusive-gateway conditions against the ROOT variables only (not the
|
|
2103
|
+
// gateway's scope, as Zeebe does). The node's result vars are node-local (see ioMappingLines), so a
|
|
2104
|
+
// gateway reading `status`/`<emit>` directly would never see them; reading `<el>_contractMet` /
|
|
2105
|
+
// `<el>_retryRequested` works on either engine semantics and can never collide ACROSS nodes. The
|
|
2106
|
+
// source var ({@link factSourceVar}) of EVERY declared emit (required + non-required) is reserved so
|
|
2107
|
+
// BOTH the resume-valid flag (PR #876) AND these control vars are grown collision-free against all of
|
|
2108
|
+
// them — an emit whose source var equals the bare control name (e.g. an agent emit literally named
|
|
2109
|
+
// `<el>_contractMet`) would otherwise be null-seeded node-local and SHADOW the root control the outer
|
|
2110
|
+
// gateway reads, so the producer could never pass (issue #863 review, thread r4199435326).
|
|
2111
|
+
const reservedFlagTargets = nodeEmits.map((f) => factSourceVar(kind, f));
|
|
2112
|
+
const contractMetVar = nodeControlVar(el, "contractMet", reservedFlagTargets);
|
|
2001
2113
|
const taskExt = [
|
|
2002
2114
|
" <bpmn:extensionElements>",
|
|
2003
2115
|
` <zeebe:taskDefinition ${taskDefAttr} />`,
|
|
2004
2116
|
...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
|
|
2005
2117
|
...taskHeaders,
|
|
2118
|
+
" <zeebe:ioMapping>",
|
|
2119
|
+
// Preflight guard: the node body is configured from the runner-seeded root `nodeInputs.<el>`. If that
|
|
2120
|
+
// seed is missing (lost root variables — nano-bpm#1331 wiped them on 171774/171309), every `cfg(...)`
|
|
2121
|
+
// on the subProcess silently evaluated to null and the agent ran BLIND (no prompt/timeout), reporting
|
|
2122
|
+
// `blocked` into an unexplained producer-contract escalation. Fail LOUD instead: a FEEL `assert` makes
|
|
2123
|
+
// this input raise an incident naming the cause, BEFORE a job exists; once the root variables are
|
|
2124
|
+
// restored, resolving the incident re-applies the inputs and the job is created. It sits on the inner
|
|
2125
|
+
// LEAF task, not the subProcess: a leaf input incident parks the token on every engine (#946), whereas
|
|
2126
|
+
// a subProcess input incident was completed past its body by the drain sweep (nano-bpm#1334).
|
|
2127
|
+
// KNOWN LIMITATION (fail-loud-only, nano-workforce#866): resolving this incident re-evaluates ONLY
|
|
2128
|
+
// this leaf's inputs — the subProcess-level config mappings (prompt/appendPrompt/nodeTimeout,
|
|
2129
|
+
// connector target/payload/dedupeKey) ran ONCE at subProcess entry and are NOT re-mapped on resolve.
|
|
2130
|
+
// The in-subprocess "Retry this step" loop does NOT help either: it loops directly back to the inner
|
|
2131
|
+
// service task (to reset the node-local scratch), bypassing the subProcess entry mappings, so it too
|
|
2132
|
+
// leaves the config null. Recovery therefore requires re-entering the sub-process from OUTSIDE —
|
|
2133
|
+
// relaunch/re-enter the node so its subProcess input mappings re-evaluate against the restored
|
|
2134
|
+
// `nodeInputs` — not merely resolving the incident or using "Retry this step". The incident message
|
|
2135
|
+
// says so. The correct fix is to run this check at sub-process ENTRY — Camunda parity, where a failed
|
|
2136
|
+
// sub-process input mapping parks in `activating` and resolving re-evaluates ALL inputs — which
|
|
2137
|
+
// nano-bpm#1336 restores; until then we fail loud here rather than risk the #1334 drain-past.
|
|
2138
|
+
` <zeebe:input ${attr("source", nodeInputsPreflightFeel(el))} target="${NODE_INPUTS_PREFLIGHT_VAR}" />`,
|
|
2139
|
+
...(contractGate !== undefined
|
|
2140
|
+
? [` <zeebe:output ${attr("source", agentContractProceedCondition(contractGate.requiredEmits))} target="${contractMetVar}" />`]
|
|
2141
|
+
: []),
|
|
2142
|
+
" </zeebe:ioMapping>",
|
|
2006
2143
|
" </bpmn:extensionElements>",
|
|
2007
2144
|
];
|
|
2145
|
+
// The timeout escalation is resumable with the node's declared emits too: work finished out of band
|
|
2146
|
+
// (or a draft PR the stalled agent already opened) can be handed onward instead of threading null.
|
|
2147
|
+
// `nodeEmits` is the node's OWN declared emits (for an agent, the contract gate's emits; for a
|
|
2148
|
+
// connector, its own — a connector declares `emits` and its emit source is the fact's own name, so
|
|
2149
|
+
// keying off the absent agent-only gate would drop them and map no `value` on a Continue resume).
|
|
2150
|
+
const allEmits = nodeEmits;
|
|
2151
|
+
const contractEsc = contractEscalationTaskElement(el);
|
|
2152
|
+
const contractEmits = contractGate?.requiredEmits ?? [];
|
|
2008
2153
|
const timeoutEscalation = escalationTaskLines(
|
|
2009
2154
|
esc,
|
|
2010
2155
|
nodeId,
|
|
2011
2156
|
[`${el}_i2`],
|
|
2012
2157
|
`${el}_i3`,
|
|
2013
|
-
escalationContextFeel(
|
|
2158
|
+
`${escalationContextFeel(
|
|
2014
2159
|
nodeId,
|
|
2015
2160
|
descriptor,
|
|
2016
2161
|
"nodeTimeout",
|
|
2017
2162
|
"; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
|
|
2018
|
-
),
|
|
2163
|
+
)} + ${feelStr(escalationResolutionHint(nodeEmits, resumeEmits, isAgent))}`,
|
|
2019
2164
|
{
|
|
2020
|
-
//
|
|
2021
|
-
//
|
|
2022
|
-
//
|
|
2023
|
-
|
|
2024
|
-
// — a no-emit node keeps its inert acknowledgement form.
|
|
2025
|
-
...(timeoutResume !== undefined && timeoutResume.emits.length > 0 ? { resume: timeoutResume, validTarget: `${el}_end` } : {}),
|
|
2165
|
+
// #863 Retry + #876 Continue/validate, nested: the timeout escalation routes through a retry gate
|
|
2166
|
+
// (`retryElement`) and, on Continue with a required emit, a fail-closed validation gate
|
|
2167
|
+
// (`validTarget`) that re-parks a null/invalid value instead of threading it downstream.
|
|
2168
|
+
...(resumeEmits.length > 0 ? { resume: { kind, emits: resumeEmits, declaredEmits: nodeEmits }, validTarget: `${el}_end` } : {}),
|
|
2026
2169
|
displayName: taskName,
|
|
2170
|
+
retryElement: el,
|
|
2027
2171
|
reservedTargets: reservedFlagTargets,
|
|
2028
2172
|
},
|
|
2029
2173
|
);
|
|
@@ -2032,6 +2176,7 @@ function serviceBodyLines(
|
|
|
2032
2176
|
` <bpmn:serviceTask id="${el}_task" name="${escapeXml(taskName)}">`,
|
|
2033
2177
|
...taskExt,
|
|
2034
2178
|
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
2179
|
+
` <bpmn:incoming>${el}_r1</bpmn:incoming>`,
|
|
2035
2180
|
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
2036
2181
|
" </bpmn:serviceTask>",
|
|
2037
2182
|
` <bpmn:boundaryEvent id="${el}_be" name="Node timed out" attachedToRef="${el}_task">`,
|
|
@@ -2040,44 +2185,48 @@ function serviceBodyLines(
|
|
|
2040
2185
|
" </bpmn:boundaryEvent>",
|
|
2041
2186
|
...timeoutEscalation,
|
|
2042
2187
|
];
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2047
|
-
|
|
2048
|
-
|
|
2188
|
+
const timeoutResumable = resumeEmits.length > 0;
|
|
2189
|
+
const contractResumable = isAgent && contractEmits.length > 0;
|
|
2190
|
+
// #863 shared reset: both escalations' retry branches (`${esc}Rr` / `${contractEsc}Rr`) loop into ONE
|
|
2191
|
+
// reset-throw that clears the node-local scratch (including each escalation's resume-valid flag) then
|
|
2192
|
+
// re-enters the task. Per-escalation retry gates (built in `escalationTaskLines`) feed it, so a Continue
|
|
2193
|
+
// re-park knows WHICH escalation to return to while Retry is funnelled through this single reset.
|
|
2194
|
+
const resetFlagVars: string[] = [];
|
|
2195
|
+
if (timeoutResumable) resetFlagVars.push(escalationResumeFlagVar(esc, { kind, emits: resumeEmits }, reservedFlagTargets));
|
|
2196
|
+
if (contractResumable) resetFlagVars.push(escalationResumeFlagVar(contractEsc, { kind: "agent", emits: contractEmits }, reservedFlagTargets));
|
|
2197
|
+
const retryIncoming = [`${esc}Rr`, ...(isAgent ? [`${contractEsc}Rr`] : [])];
|
|
2198
|
+
const reset = retryResetLines(el, kind, allEmits, resetFlagVars, retryIncoming);
|
|
2049
2199
|
|
|
2050
2200
|
if (contractGate === undefined) {
|
|
2051
2201
|
return [
|
|
2052
2202
|
...head,
|
|
2053
|
-
|
|
2203
|
+
...reset,
|
|
2204
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${timeoutResumable ? `${esc}Vok` : `${esc}Rc`}</bpmn:incoming></bpmn:endEvent>`,
|
|
2054
2205
|
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
2055
2206
|
flow(`${el}_i1`, `${el}_task`, `${el}_end`),
|
|
2056
2207
|
flow(`${el}_i2`, `${el}_be`, esc),
|
|
2057
|
-
flow(`${el}_i3`, esc,
|
|
2208
|
+
flow(`${el}_i3`, esc, `${esc}Rg`),
|
|
2058
2209
|
];
|
|
2059
2210
|
}
|
|
2060
2211
|
|
|
2061
2212
|
// Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end. When the
|
|
2062
2213
|
// contract escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_g2` routes to
|
|
2063
2214
|
// the GATE (not straight to the end), and the gate's valid branch flows to the end (PR #876 review).
|
|
2064
|
-
const
|
|
2065
|
-
const
|
|
2066
|
-
const contractResumed = emits.length > 0;
|
|
2067
|
-
const contractReturnTarget = contractResumed ? `${contractEsc}Vg` : `${el}_end`;
|
|
2068
|
-
const proceedCondition = agentContractProceedCondition(emits);
|
|
2215
|
+
const emits = contractEmits;
|
|
2216
|
+
const proceedCondition = `=${contractMetVar} = true`;
|
|
2069
2217
|
const contractEscalation = escalationTaskLines(
|
|
2070
2218
|
contractEsc,
|
|
2071
2219
|
nodeId,
|
|
2072
2220
|
[`${el}_g1`],
|
|
2073
2221
|
`${el}_g2`,
|
|
2074
|
-
agentContractContextFeel(nodeId, descriptor, emits),
|
|
2222
|
+
agentContractContextFeel(nodeId, descriptor, emits, nodeEmits),
|
|
2075
2223
|
// Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
|
|
2076
2224
|
// the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
|
|
2077
2225
|
// so the downstream consumer late-binds a real value instead of the null that poisoned it (#731) —
|
|
2078
2226
|
// VALIDATED by the post-escalation gate (`validTarget`) so an omitted/malformed value re-parks
|
|
2079
|
-
// instead of threading null downstream (PR #876 review)
|
|
2080
|
-
|
|
2227
|
+
// instead of threading null downstream (PR #876 review), and routed through the retry gate
|
|
2228
|
+
// (`retryElement`) so Retry resets+reruns the node (#863).
|
|
2229
|
+
{ ...(contractResumable ? { resume: { kind: "agent" as const, emits, declaredEmits: nodeEmits }, validTarget: `${el}_end` } : {}), displayName: taskName, retryElement: el, reservedTargets: reservedFlagTargets },
|
|
2081
2230
|
);
|
|
2082
2231
|
return [
|
|
2083
2232
|
...head,
|
|
@@ -2087,24 +2236,189 @@ function serviceBodyLines(
|
|
|
2087
2236
|
` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
|
|
2088
2237
|
" </bpmn:exclusiveGateway>",
|
|
2089
2238
|
...contractEscalation,
|
|
2090
|
-
|
|
2239
|
+
...reset,
|
|
2240
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming><bpmn:incoming>${timeoutResumable ? `${esc}Vok` : `${esc}Rc`}</bpmn:incoming><bpmn:incoming>${contractResumable ? `${contractEsc}Vok` : `${contractEsc}Rc`}</bpmn:incoming></bpmn:endEvent>`,
|
|
2091
2241
|
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
2092
2242
|
flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
|
|
2093
2243
|
` <bpmn:sequenceFlow id="${el}_g0" name="contract met" sourceRef="${el}_gate" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${proceedCondition}</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
2094
2244
|
` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
|
|
2095
|
-
flow(`${el}_g2`, contractEsc,
|
|
2245
|
+
flow(`${el}_g2`, contractEsc, `${contractEsc}Rg`),
|
|
2096
2246
|
flow(`${el}_i2`, `${el}_be`, esc),
|
|
2097
|
-
flow(`${el}_i3`, esc,
|
|
2247
|
+
flow(`${el}_i3`, esc, `${esc}Rg`),
|
|
2098
2248
|
];
|
|
2099
2249
|
}
|
|
2100
2250
|
|
|
2251
|
+
/** The operator-facing "how do I resolve this" sentence appended to a service node's escalation
|
|
2252
|
+
* prompt (retry-node resolution): every escalation names its two exits so the task is actionable on
|
|
2253
|
+
* its own, without reading the BPMN. */
|
|
2254
|
+
function escalationResolutionHint(nodeEmits: readonly DeliveryFact[], resumeEmits: readonly DeliveryFact[], isAgent: boolean): string {
|
|
2255
|
+
const retry =
|
|
2256
|
+
` To resolve: choose Resolution "Retry this step" (${ESCALATION_DECISION_VAR}="${ESCALATION_DECISION_RETRY}") to re-run this node` +
|
|
2257
|
+
(isAgent ? " — the note is passed to the agent as guidance for the retry" : "");
|
|
2258
|
+
// Mirror the compiled topology (escalationTaskLines): a value-resumable Continue is offered ONLY when
|
|
2259
|
+
// the node declares EXACTLY ONE emit AND that emit is the required resume target. Gating on the
|
|
2260
|
+
// required count alone would promise a value field a node declaring two facts (one required) never
|
|
2261
|
+
// renders (issue #863 review, thread r4193902690).
|
|
2262
|
+
const names = (fs: readonly DeliveryFact[]): string => fs.map((e) => `'${e.name}'`).join("/");
|
|
2263
|
+
let proceed: string;
|
|
2264
|
+
if (nodeEmits.length === 1 && resumeEmits.length === 1) {
|
|
2265
|
+
proceed = `; or choose "Continue" and put the '${resumeEmits[0].name}' value in the value field (e.g. work finished out of band)`;
|
|
2266
|
+
} else if (nodeEmits.length > 1 && resumeEmits.length >= 1) {
|
|
2267
|
+
// A multi-declared node that still owes ≥1 REQUIRED emit: the resume-valid flag is hard-`false`, so
|
|
2268
|
+
// the post-escalation validation gate ALWAYS re-parks — Continue loops back HERE, it does NOT write
|
|
2269
|
+
// nulls or advance to a fallback branch (deliveryGraphCompiler.ts fail-closed loop; #863 review
|
|
2270
|
+
// r4194186490/r4194186441). Only "Retry this step" can produce the facts.
|
|
2271
|
+
proceed = `; "Continue" CANNOT resume this node and does NOT advance — the single value field cannot supply its ${names(nodeEmits)} facts, so it re-parks HERE until you choose "Retry this step" to actually produce them`;
|
|
2272
|
+
} else if (nodeEmits.length > 1) {
|
|
2273
|
+
// Multi-declared but NO required resume target downstream: the escalation is not validation-gated, so
|
|
2274
|
+
// Continue genuinely proceeds past the node to its default (fallback) branch.
|
|
2275
|
+
proceed = `; or choose "Continue" to proceed past this node to its default (fallback) branch — no downstream node requires its ${names(nodeEmits)} facts, and the single value field cannot supply them anyway`;
|
|
2276
|
+
} else {
|
|
2277
|
+
proceed = '; or choose "Continue" to proceed past this node';
|
|
2278
|
+
}
|
|
2279
|
+
return `${retry}${proceed}.`;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/** Grow a node-unique ROOT control var (`<el>_contractMet` / `<el>_retryRequested`) collision-free
|
|
2283
|
+
* (deterministic trailing `_`) against the node-local emit SOURCE vars ({@link factSourceVar}) that
|
|
2284
|
+
* share this node's flat subProcess scope. These controls are decided INSIDE the node's scope yet must
|
|
2285
|
+
* be READ by the OUTER gateways (Nano evaluates exclusive-gateway conditions against the ROOT vars
|
|
2286
|
+
* only), so they are published at root. But an emit whose source var equals the bare control name
|
|
2287
|
+
* (e.g. an agent emit literally named `n0_contractMet` on the first node `n0`, whose source var IS its
|
|
2288
|
+
* own name) is ALSO null-seeded node-local by {@link ioMappingLines} — and Nano's nearest-scope
|
|
2289
|
+
* propagation then writes the gate/escalation output into that LOCAL shadow instead of the root the
|
|
2290
|
+
* outer gateway reads, so the gateway never sees `true` and the producer can never pass (issue #863
|
|
2291
|
+
* review, thread r4199435326). Growing the control name clear of every emit source var removes the
|
|
2292
|
+
* whole class, exactly as {@link escalationResumeFlagVar} grows the resume-valid flag. */
|
|
2293
|
+
function nodeControlVar(el: string, suffix: string, reservedTargets: readonly string[]): string {
|
|
2294
|
+
const reserved = new Set<string>(reservedTargets);
|
|
2295
|
+
let name = `${el}_${suffix}`;
|
|
2296
|
+
while (reserved.has(name)) name = `${name}_`;
|
|
2297
|
+
return name;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/** The node-unique ROOT boolean an escalation completion publishes for the retry gateway (see the
|
|
2301
|
+
* gateway-scope note in {@link serviceBodyLines}), grown collision-free ({@link nodeControlVar})
|
|
2302
|
+
* against the node's emit source vars so a same-named emit can neither shadow nor collide with it. The
|
|
2303
|
+
* SAME `reservedTargets` must be passed at the write and read sites so the grown name agrees. */
|
|
2304
|
+
function retryRequestedVar(el: string, reservedTargets: readonly string[] = []): string {
|
|
2305
|
+
return nodeControlVar(el, "retryRequested", reservedTargets);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
/** The retry-node resolution RESET shared by a service node's escalations (`__esc`, and an agent's
|
|
2309
|
+
* `__contract`): their per-escalation retry gates (built in {@link escalationTaskLines}) each loop
|
|
2310
|
+
* `─retry→` this single reset, which clears the node-local scratch then re-enters the task (`_r1`). The
|
|
2311
|
+
* reset (a none intermediate throw event carrying output mappings only — the compiler never emits a
|
|
2312
|
+
* scriptTask) clears the node-local decision and the previous attempt's emits (a rerun that succeeds
|
|
2313
|
+
* may report no status at all; a stale emit would otherwise be republished downstream) — plus, for an
|
|
2314
|
+
* agent, its FULL declared result set (every {@link AGENT_RESULT_LOCAL_VARS} field, so a stale
|
|
2315
|
+
* `blocked` status can't fail the contract gate again and a stale `transcriptUrl`/PR alias/other
|
|
2316
|
+
* optional field can't republish downstream) — plus each escalation's resume-valid flag
|
|
2317
|
+
* (`resumeFlagVars`, so a Retry can never leave a stale `true` that the next Continue's validation gate
|
|
2318
|
+
* would read) — and appends the operator's {@link ESCALATION_NOTE_VAR} to the agent prompt as retry
|
|
2319
|
+
* guidance. All targets are node-local (declared on the subProcess by `ioMappingLines`), so nothing
|
|
2320
|
+
* leaks to the root. `incoming` is each feeding escalation's retry branch (`${esc}Rr`). */
|
|
2321
|
+
function retryResetLines(el: string, kind: "agent" | "connector", emits: readonly DeliveryFact[], resumeFlagVars: readonly string[], incoming: readonly string[]): string[] {
|
|
2322
|
+
const isAgent = kind === "agent";
|
|
2323
|
+
const outputs: { source: string; target: string }[] = [];
|
|
2324
|
+
// Clear the node's declared emits for ANY emitting kind (agent or connector) so a retry never
|
|
2325
|
+
// republishes a stale value; for an agent, also clear the FULL declared result set
|
|
2326
|
+
// (AGENT_RESULT_LOCAL_VARS — every field declared node-local, not just the five self-reported status
|
|
2327
|
+
// fields) so a retried worker that omits an optional field (transcriptUrl, agentCheckpoint, a PR
|
|
2328
|
+
// alias, exitCode, …) cannot let the previous attempt's value republish downstream or surface in the
|
|
2329
|
+
// next escalation. For a connector, clear its fixed result metadata (CONNECTOR_RESULT_LOCAL_VARS) for
|
|
2330
|
+
// the same reason. The status/summary fields and prompt-guidance are agent-specific (a connector has
|
|
2331
|
+
// no self-reported status contract or prompt).
|
|
2332
|
+
const cleared = new Set<string>(isAgent ? AGENT_RESULT_LOCAL_VARS : CONNECTOR_RESULT_LOCAL_VARS);
|
|
2333
|
+
for (const f of emits) cleared.add(factSourceVar(kind, f));
|
|
2334
|
+
// Clear each escalation's resume-valid flag too (PR #876 flag × #863 retry): a Retry resets the
|
|
2335
|
+
// node-local scratch, so a stale `true` can never survive into the next Continue's validation gate.
|
|
2336
|
+
for (const v of resumeFlagVars) cleared.add(v);
|
|
2337
|
+
for (const v of cleared) outputs.push({ source: "=null", target: v });
|
|
2338
|
+
if (isAgent) {
|
|
2339
|
+
// Read the OPERATOR's note from the escalation-specific `escalationNote` control — NEVER the plain
|
|
2340
|
+
// `note`: `note` is a documented WORKER RESULT field (resources/prompts/plan.md returns one) that
|
|
2341
|
+
// nearest-scope propagation lands in this same subProcess scope, so reading it here would feed a
|
|
2342
|
+
// worker-produced note back to the agent as "Operator guidance" whenever the operator retries
|
|
2343
|
+
// WITHOUT a note (the form permits omitting it) — PR #863 Copilot "Previously missed".
|
|
2344
|
+
const hasNote = `(is defined(${ESCALATION_NOTE_VAR}) and ${ESCALATION_NOTE_VAR} != null and string(${ESCALATION_NOTE_VAR}) != "")`;
|
|
2345
|
+
// Re-derive from the RUNNER-SEEDED BASELINE (`nodeInputs.<el>.appendPrompt`), never the live
|
|
2346
|
+
// `appendPrompt`: the subProcess input seeds `appendPrompt` from `nodeInputs` only at subProcess
|
|
2347
|
+
// ENTRY, so on a retry re-entry the task still reads this var as the last reset left it — building
|
|
2348
|
+
// on the live value would carry the previous retry's note forward and ACCUMULATE one stale
|
|
2349
|
+
// "Operator guidance…" paragraph per consecutive retry.
|
|
2350
|
+
const seeded = `nodeInputs.${el}.appendPrompt`;
|
|
2351
|
+
const base = `(if (is defined(${seeded}) and ${seeded} != null) then ${seeded} + "\n\n" else "")`;
|
|
2352
|
+
outputs.push({
|
|
2353
|
+
source: `=if ${hasNote} then ${base} + "Operator guidance for this retry: " + string(${ESCALATION_NOTE_VAR}) else (if (is defined(${seeded})) then ${seeded} else null)`,
|
|
2354
|
+
target: "appendPrompt",
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
// Clear the escalation controls (ESCALATION_LOCAL_VARS = decision/value/escalationNote), derived from
|
|
2358
|
+
// the ONE source so a renamed control can't drift a second hardcoded list. These are declared
|
|
2359
|
+
// node-local by ioMappingLines for BOTH kinds (agent and connector), so each clear lands in this
|
|
2360
|
+
// subProcess scope, never the shared root.
|
|
2361
|
+
//
|
|
2362
|
+
// Do NOT clear the plain `note` here. `note` is a WORKER RESULT field that is node-local ONLY for an
|
|
2363
|
+
// agent (it is in AGENT_RESULT_LOCAL_VARS, so the `cleared` result-set loop above already resets it on
|
|
2364
|
+
// an agent retry). A connector does NOT declare `note` node-local (CONNECTOR_RESULT_LOCAL_VARS omits
|
|
2365
|
+
// it, and it is neither an escalation control nor a connector emit), so an unconditional `note = null`
|
|
2366
|
+
// here would land at the SHARED ROOT — leaking null across the node-isolation boundary and clobbering a
|
|
2367
|
+
// parallel node's `note` — while for an agent it merely duplicates the `cleared` loop's reset
|
|
2368
|
+
// (deliveryGraphCompiler.ts:2181, PR #863 Copilot "Previously missed").
|
|
2369
|
+
for (const v of ESCALATION_LOCAL_VARS) outputs.push({ source: "=null", target: v });
|
|
2370
|
+
return [
|
|
2371
|
+
` <bpmn:intermediateThrowEvent id="${el}_retry" name="Reset for retry">`,
|
|
2372
|
+
" <bpmn:extensionElements>",
|
|
2373
|
+
" <zeebe:ioMapping>",
|
|
2374
|
+
...outputs.map((o) => ` <zeebe:output ${attr("source", o.source)} target="${o.target}" />`),
|
|
2375
|
+
" </zeebe:ioMapping>",
|
|
2376
|
+
" </bpmn:extensionElements>",
|
|
2377
|
+
...incoming.map((id) => ` <bpmn:incoming>${id}</bpmn:incoming>`),
|
|
2378
|
+
` <bpmn:outgoing>${el}_r1</bpmn:outgoing>`,
|
|
2379
|
+
" </bpmn:intermediateThrowEvent>",
|
|
2380
|
+
flow(`${el}_r1`, `${el}_retry`, `${el}_task`),
|
|
2381
|
+
];
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/** The node-unique internal resume-valid flag ({@link resumeValidVar}) an escalation publishes for its
|
|
2385
|
+
* post-escalation validation gate, GROWN collision-free (deterministic trailing `_`) against every
|
|
2386
|
+
* declared emit's source var (`reservedTargets`) plus the resumed required emits (PR #876 review). The
|
|
2387
|
+
* SAME computation must run in both {@link escalationTaskLines} (writer + gate condition) and the
|
|
2388
|
+
* {@link retryResetLines} reset (clear), so it lives in one shared helper. Returns `""` for a
|
|
2389
|
+
* non-resumable escalation (no flag). */
|
|
2390
|
+
function escalationResumeFlagVar(
|
|
2391
|
+
esc: string,
|
|
2392
|
+
resume: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] } | undefined,
|
|
2393
|
+
reservedTargets: readonly string[],
|
|
2394
|
+
): string {
|
|
2395
|
+
if (resume === undefined || resume.emits.length === 0) return "";
|
|
2396
|
+
const reserved = new Set<string>(reservedTargets);
|
|
2397
|
+
for (const f of resume.emits) reserved.add(factSourceVar(resume.kind, f));
|
|
2398
|
+
return growFlagClear(resumeValidVar(esc), reserved);
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
/** Grow a base internal-flag var ({@link resumeValidVar}) clear of a reserved set with a deterministic
|
|
2402
|
+
* trailing `_`, so a user-declared fact/control whose name happens to EQUAL the generated flag can never
|
|
2403
|
+
* shadow it. Shared by {@link escalationResumeFlagVar} (service-node emit-source vars) and the `human`
|
|
2404
|
+
* node's completion gate (whose declared emit fact NAME is null-seeded node-local by {@link
|
|
2405
|
+
* ioMappingLines} — a bare {@link resumeValidVar} would otherwise land in that null-seeded var and the
|
|
2406
|
+
* immediately-following completion gateway would read the stale `null`, re-parking a valid answer
|
|
2407
|
+
* forever; Copilot review #863, thread r4201222762). Returns the base unchanged when no collision, so
|
|
2408
|
+
* the common case emits byte-identical output. */
|
|
2409
|
+
function growFlagClear(base: string, reserved: ReadonlySet<string>): string {
|
|
2410
|
+
let flag = base;
|
|
2411
|
+
while (reserved.has(flag)) flag = `${flag}_`;
|
|
2412
|
+
return flag;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2101
2415
|
/** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
|
|
2102
2416
|
* `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
|
|
2103
2417
|
* never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
|
|
2104
2418
|
function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>, displayName: string): string[] {
|
|
2105
2419
|
const nodeId = node.id;
|
|
2106
2420
|
const name = escapeXml(displayName);
|
|
2107
|
-
const esc = escalationTaskElement(el);
|
|
2421
|
+
const esc = escalationTaskElement(el, "wait");
|
|
2108
2422
|
const emits = normaliseEmits(node);
|
|
2109
2423
|
// `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
|
|
2110
2424
|
// human-completable escalation task; `continue` proceeds past the gate as not-ready WITHOUT a human
|
|
@@ -2121,7 +2435,12 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
2121
2435
|
const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
|
|
2122
2436
|
// When the escalation is RESUMABLE (it grew a validation gate), its return flow `_i5` routes to the
|
|
2123
2437
|
// GATE (not straight to the end) and the gate's valid branch flows to the end (PR #876 review).
|
|
2124
|
-
|
|
2438
|
+
// Only a SINGLE-emit wait is genuinely value-resumable: the single escalation-form `value` field
|
|
2439
|
+
// cannot supply >1 distinct fact, and a wait gate has no Retry escape, so a MULTI-emit wait grows NO
|
|
2440
|
+
// validation gate (building one would wedge the token in an unresolvable loop — issue #863 review
|
|
2441
|
+
// r4198123619). A multi-emit (or no-emit) wait escalation therefore flows `_i5` straight to the node
|
|
2442
|
+
// end — an acknowledged, value-less continue — the same path as a no-emit wait.
|
|
2443
|
+
const waitResumed = !continueOnTimeout && emits.length === 1;
|
|
2125
2444
|
// Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
|
|
2126
2445
|
// tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
|
|
2127
2446
|
// resolved target/match, and a compact summary of the candidate releases the probe observed.
|
|
@@ -2209,8 +2528,10 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
2209
2528
|
waitEscalationContextFeel(nodeId),
|
|
2210
2529
|
// A resumed wait-gate escalation is VALIDATED by the post-escalation gate (`validTarget`) so
|
|
2211
2530
|
// an omitted/malformed operator value re-parks instead of threading null onto the emit the
|
|
2212
|
-
// downstream consumer binds (PR #876 review).
|
|
2213
|
-
|
|
2531
|
+
// downstream consumer binds (PR #876 review). Only a SINGLE-emit wait is value-resumable, so
|
|
2532
|
+
// `validTarget` (and the gate it drives) is provided ONLY then — a multi-emit wait has no
|
|
2533
|
+
// escapable resume and so grows no gate (issue #863 review r4198123619).
|
|
2534
|
+
{ resume: { kind: node.kind, emits }, diagnosticInputs, displayName, reservedTargets: emits.map((f) => factSourceVar(node.kind, f)), ...(emits.length === 1 ? { validTarget: `${el}_end` } : {}) },
|
|
2214
2535
|
)),
|
|
2215
2536
|
// On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
|
|
2216
2537
|
// human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
|
|
@@ -2231,33 +2552,168 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
2231
2552
|
* + SLA. On completion the form's captured typed value is output (`humanEmitValue`/`humanEmitArtifact`
|
|
2232
2553
|
* — the subProcess ioMapping then publishes it as the node's fact); on SLA expiry the node records an
|
|
2233
2554
|
* `escalated` outcome and settles (bounded — the graph cannot silently wedge). Mirrors the standalone
|
|
2234
|
-
* `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`).
|
|
2235
|
-
|
|
2555
|
+
* `delivery-human.bpmn` shape, reusing the S3 form + emit-var contract (`deliveryHuman.ts`).
|
|
2556
|
+
*
|
|
2557
|
+
* `singleEmit` (the node's ONE declared fact, when it declares exactly one) lets a bespoke explicit
|
|
2558
|
+
* form capture its value under the FACT'S OWN NAME — the same precedence `bindHumanEmits` honours
|
|
2559
|
+
* (fact name first, then the canonical `value`/`resolvedArtifact` keys). The userTask output source
|
|
2560
|
+
* therefore prefers `<factName>` and falls back to the canonical control, so a custom form keyed on the
|
|
2561
|
+
* fact (`{ approval: "yes" }` for an `approval` emit) publishes its value instead of NULL, while the
|
|
2562
|
+
* generic/publish/ack forms (which capture `value`/`resolvedArtifact`) are unaffected (PR #863 review,
|
|
2563
|
+
* thread deliveryGraphCompiler.ts:1929). Fact names are `FACT_NAME_PATTERN`-constrained
|
|
2564
|
+
* (`[A-Za-z_][A-Za-z0-9_]*`), so the name embeds safely as a FEEL identifier. The selected value is then
|
|
2565
|
+
* COERCED/VALIDATED to the emit's declared type via {@link coerceFactValueFeel} (the same typed-binding
|
|
2566
|
+
* contract the escalation resume uses), so a bespoke `boolean`/`number` form publishes a real FEEL
|
|
2567
|
+
* boolean/number — not the raw string `"true"`/`"1"` that a downstream guarded split (`= true`/`= 1`)
|
|
2568
|
+
* would never match (PR #863 thread r4189815989). */
|
|
2569
|
+
function humanBodyLines(el: string, displayName: string, formId: string, singleEmit?: DeliveryFact, preferFactName = false, requiredEmit = false): string[] {
|
|
2236
2570
|
const task = humanTaskElement(el);
|
|
2237
2571
|
const assignee =
|
|
2238
2572
|
'=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != "") then escalationAssignee else null';
|
|
2573
|
+
// The canonical capture key for the single emit's TYPE (artifact → the publish form's
|
|
2574
|
+
// `resolvedArtifact`; anything else → the generic form's `value`). `selectExpr(canonical)` is a bare
|
|
2575
|
+
// (no leading `=`) null-safe FEEL expression; when `preferFactName` (an EXPLICIT bespoke form only) it
|
|
2576
|
+
// reads `<factName>` first, then the canonical control — a built-in form's fixed control otherwise.
|
|
2577
|
+
// The fact-named read is STRINGABILITY-guarded (`string(<factName>) != null`) because `is defined(X)`
|
|
2578
|
+
// is TRUE and `X != null` holds for a FEEL BUILTIN function (`count`, `sum`, …) when a blank form
|
|
2579
|
+
// leaves no task variable of that name — so a bare presence guard would SELECT the builtin FUNCTION as
|
|
2580
|
+
// the `then` arm for the emit. The number/boolean coercers reject a non-stringable operand, but the text
|
|
2581
|
+
// types (`string`/`version`/`url`/`pr`) pass the selection through verbatim and the artifact source is
|
|
2582
|
+
// never coerced, so without this guard those fact types would select the builtin function rather than
|
|
2583
|
+
// fall back to the canonical control / null. On the pinned WASM engine a function-valued selection is
|
|
2584
|
+
// itself folded to null before it can be published (a FEEL variable binding cannot hold a function), so
|
|
2585
|
+
// the guard is defence-in-depth THERE — but it is NOT a no-op: it makes the null-fold explicit and
|
|
2586
|
+
// engine-independent (an engine that published the function instead would corrupt the fact or incident
|
|
2587
|
+
// on the downstream io-mapping), and it is what the compiler-level FEEL assertion in
|
|
2588
|
+
// `deliveryGraphCompiler.test.ts` pins red-before/green-after. `string(<builtin>)` folds to null, so the
|
|
2589
|
+
// guard falls back to the canonical control / null; a real captured TEXT value stringifies to itself and
|
|
2590
|
+
// is still selected. The guard sits ONLY on the fact-named candidate — the canonical `resolvedArtifact`
|
|
2591
|
+
// object in the else arm is untouched, so a genuine object handle still passes through (Copilot review
|
|
2592
|
+
// #863, "Guard fact names that shadow FEEL builtins").
|
|
2593
|
+
const selectExpr = (canonical: string): string =>
|
|
2594
|
+
singleEmit !== undefined && preferFactName
|
|
2595
|
+
? `if (is defined(${singleEmit.name}) and ${singleEmit.name} != null and string(${singleEmit.name}) != null) then ${singleEmit.name} else if (is defined(${canonical})) then ${canonical} else null`
|
|
2596
|
+
: `if (is defined(${canonical})) then ${canonical} else null`;
|
|
2597
|
+
// A form captures the selected value as TEXT (a textfield, or an explicit form's fact-named control), so
|
|
2598
|
+
// the non-artifact single emit must be VALIDATED against the fact's declared type AND COERCED to it
|
|
2599
|
+
// before writing `humanEmitValue` — the SAME type-aware, fail-closed contract the escalation resume
|
|
2600
|
+
// enforces. That contract is absorbed into {@link coerceFactValueFeel}, whose per-type arm now VALIDATES
|
|
2601
|
+
// (not merely coerces) the selection: `boolean`/`number` already failed closed on an unparseable entry,
|
|
2602
|
+
// and `string`/`version`/`url`/`pr` now fail closed on a value that does not match the declared type's
|
|
2603
|
+
// grammar — so a generic/custom form can no longer publish a blank string, a malformed version/URL, or an
|
|
2604
|
+
// invalid PR ref directly downstream (PR #863 review 5430718031, "Validate human-task fact values before
|
|
2605
|
+
// publishing"). (The validity test is folded INTO the coercion's single `if` rather than wrapped in an
|
|
2606
|
+
// outer `if (resumeValueCondition(…)) then …` because the pinned engine mis-evaluates a nested
|
|
2607
|
+
// `if…then…else` selection inside that outer guard — verified engine-native: valid entries routed to the
|
|
2608
|
+
// default branch. Folding the check into the one `if` keeps the working single-level shape.)
|
|
2609
|
+
const valueSource =
|
|
2610
|
+
singleEmit !== undefined && singleEmit.type !== "artifact"
|
|
2611
|
+
? `=${coerceFactValueFeel(singleEmit, selectExpr("value"))}`
|
|
2612
|
+
: "=if (is defined(value)) then value else null";
|
|
2613
|
+
// The artifact source is likewise VALIDATED before publish: the publish form's `required` rule checks
|
|
2614
|
+
// only PRESENCE, so a value like `not-an-artifact` was accepted and propagated downstream despite the
|
|
2615
|
+
// canonical `pkg@version` handle contract (PR #863 review 5430718031, "Validate human artifact handles
|
|
2616
|
+
// before publishing"). Gate it on the SAME artifact grammar the escalation resume enforces
|
|
2617
|
+
// (`resumeValueCondition`'s artifact arm — scoped-package aware), fail closed to null. An artifact handle
|
|
2618
|
+
// is a text reference, so the valid arm publishes the selection verbatim (no coercion). The guard is a
|
|
2619
|
+
// FLAT `matches(trim(string(…)))` on the selection — NOT `resumeValueCondition`'s full form, whose
|
|
2620
|
+
// `is defined(<nested if…else>)` presence conjunct the pinned engine mis-evaluates when the selection is
|
|
2621
|
+
// a compound `if…then…else` (verified engine-native: a valid handle routed to null). `string(…) != null`
|
|
2622
|
+
// is the null-safe presence/stringability test; `matches()` is null-safe (a non-match yields false).
|
|
2623
|
+
const artifactSource =
|
|
2624
|
+
singleEmit !== undefined && singleEmit.type === "artifact"
|
|
2625
|
+
? (() => {
|
|
2626
|
+
const sel = selectExpr("resolvedArtifact");
|
|
2627
|
+
const s = `string(${sel})`;
|
|
2628
|
+
return `=if (${s} != null and matches(trim(${s}), "^@?[^@\\\\s]+@v?\\\\d[\\\\w.+-]*$") = true) then ${sel} else null`;
|
|
2629
|
+
})()
|
|
2630
|
+
: "=if (is defined(resolvedArtifact)) then resolvedArtifact else null";
|
|
2631
|
+
// FAIL-CLOSED COMPLETION GATE (PR #863 review, thread r4198662345): a human node whose single emit is a
|
|
2632
|
+
// downstream-REQUIRED fact must not publish an invalid (null-coerced) value onward — the edge to the
|
|
2633
|
+
// consumer is unconditional and a human node has no worker contract gate or Retry path, so a null here
|
|
2634
|
+
// activates the consumer with a null required fact. Mirror the escalation resume's fail-closed shape:
|
|
2635
|
+
// the task's OWN output mapping computes a validity flag from the SAME `selectExpr` SELECTION the emit
|
|
2636
|
+
// publishes (so an explicit bespoke form's fact-named control is validated, not a bare always-absent
|
|
2637
|
+
// field) via {@link resumeValueCondition} (the SAME per-type grammar the escalation resume enforces —
|
|
2638
|
+
// presence included, so a blank required entry is invalid too), and a post-task exclusive gateway routes
|
|
2639
|
+
// a VALID completion to the node end while an INVALID one loops back to the human task for re-entry (the
|
|
2640
|
+
// human analogue of a re-park — a human node has no separate escalation twin to re-park onto). The flag is
|
|
2641
|
+
// the node-unique {@link resumeValidVar} (the emit source is the fixed `humanEmitValue`/
|
|
2642
|
+
// `humanEmitArtifact`, never the fact's own name) — GROWN collision-free (via {@link growFlagClear})
|
|
2643
|
+
// against this node's node-local null-seeded vars (HUMAN_RESULT_LOCAL_VARS + the declared emit's fact
|
|
2644
|
+
// NAME, which `ioMappingLines` null-seeds node-local). A required single emit named EXACTLY this flag
|
|
2645
|
+
// would otherwise land the flag in that node-local null-seed and the completion gateway would read the
|
|
2646
|
+
// stale `null`, re-parking a valid answer forever (Copilot review #863, thread r4201222762 — the same
|
|
2647
|
+
// shadow class `escalationResumeFlagVar` already dodges for service-node emits).
|
|
2648
|
+
// Grown ONLY when the single emit is required — a routing-only/unconsumed emit keeps the direct
|
|
2649
|
+
// `task → end` flow (a routing-only null just takes a guarded split's deadlock-safe default, exactly
|
|
2650
|
+
// like the escalation resume's no-required-target case). The gateway routes on the SIMPLE
|
|
2651
|
+
// `<flag> = true` boolean (a gateway ioMapping is not visible downstream in the pinned engine —
|
|
2652
|
+
// verified — so the validity is computed here on the task output, not on the gateway).
|
|
2653
|
+
const gateRequired = requiredEmit && singleEmit !== undefined;
|
|
2654
|
+
const flagVar = growFlagClear(
|
|
2655
|
+
resumeValidVar(task),
|
|
2656
|
+
new Set<string>([...HUMAN_RESULT_LOCAL_VARS, ...(singleEmit !== undefined ? [singleEmit.name] : [])]),
|
|
2657
|
+
);
|
|
2658
|
+
// The completion-validity flag MUST validate the SAME selection the emit publishes (`selectExpr`), not
|
|
2659
|
+
// the BARE canonical control: for an EXPLICIT bespoke form (`preferFactName`) the emit reads the
|
|
2660
|
+
// fact-named control (`<singleEmit.name>`) FIRST, so a flag keyed on the bare `value`/`resolvedArtifact`
|
|
2661
|
+
// would read an always-undefined field and reject a GENUINELY VALID explicit-form completion forever —
|
|
2662
|
+
// the gateway's `_cbad` default re-parks the human every round until the SLA escalates (a correct human
|
|
2663
|
+
// answer could never complete an explicit-form required-emit node). Mirror the emit's selection and feed
|
|
2664
|
+
// it to `resumeValueCondition` with a FLAT `string(sel) != null` presence override — NOT the default
|
|
2665
|
+
// `is defined(<compound>)` presence, which the pinned engine mis-evaluates on a compound
|
|
2666
|
+
// `if…then…else` selection (the same reason `artifactSource` uses a flat `matches(trim(string(…)))`).
|
|
2667
|
+
const validitySource = (() => {
|
|
2668
|
+
if (singleEmit === undefined) return "=false";
|
|
2669
|
+
const sel = selectExpr(singleEmit.type === "artifact" ? "resolvedArtifact" : "value");
|
|
2670
|
+
return `=if ${resumeValueCondition(singleEmit, sel, `string(${sel}) != null`)} then true else false`;
|
|
2671
|
+
})();
|
|
2672
|
+
const endEvent = ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${gateRequired ? `${el}_cok` : `${el}_i1`}</bpmn:incoming></bpmn:endEvent>`;
|
|
2673
|
+
const completionGate: string[] = gateRequired
|
|
2674
|
+
? [
|
|
2675
|
+
` <bpmn:exclusiveGateway id="${el}_cg" name="required fact valid?" default="${el}_cbad">`,
|
|
2676
|
+
` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
|
|
2677
|
+
` <bpmn:outgoing>${el}_cok</bpmn:outgoing>`,
|
|
2678
|
+
` <bpmn:outgoing>${el}_cbad</bpmn:outgoing>`,
|
|
2679
|
+
" </bpmn:exclusiveGateway>",
|
|
2680
|
+
` <bpmn:sequenceFlow id="${el}_cok" name="valid" sourceRef="${el}_cg" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
2681
|
+
` <bpmn:sequenceFlow id="${el}_cbad" name="invalid — re-enter" sourceRef="${el}_cg" targetRef="${task}" />`,
|
|
2682
|
+
]
|
|
2683
|
+
: [];
|
|
2239
2684
|
return [
|
|
2240
2685
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
2241
2686
|
` <bpmn:userTask id="${task}" name="Delivery: human step — ${escapeXml(displayName)}">`,
|
|
2242
2687
|
" <bpmn:extensionElements>",
|
|
2243
|
-
`
|
|
2688
|
+
// `formId` is the node's RESOLVED form key, which for an explicit `human.formKey` is OPERATOR-SUPPLIED
|
|
2689
|
+
// text — render it through `attr()` (which switches to a single-quote delimiter when the value contains
|
|
2690
|
+
// a `"`) so a quote-bearing key can never break out of the attribute and inject BPMN. Authoring-time
|
|
2691
|
+
// validation (`deliveryGraph.ts`) additionally rejects a URL-/credential-/control-char-bearing formKey
|
|
2692
|
+
// before it ever reaches here, so this is defence in depth, not the only gate.
|
|
2693
|
+
` <zeebe:formDefinition ${attr("formId", formId)} />`,
|
|
2244
2694
|
" <zeebe:userTask />",
|
|
2245
2695
|
` <zeebe:assignmentDefinition candidateGroups="operators" ${attr("assignee", assignee)} />`,
|
|
2246
2696
|
" <zeebe:ioMapping>",
|
|
2247
2697
|
` <zeebe:output ${attr("source", '="completed"')} target="humanOutcome" />`,
|
|
2248
|
-
` <zeebe:output ${attr("source",
|
|
2249
|
-
` <zeebe:output ${attr("source",
|
|
2698
|
+
` <zeebe:output ${attr("source", valueSource)} target="humanEmitValue" />`,
|
|
2699
|
+
` <zeebe:output ${attr("source", artifactSource)} target="humanEmitArtifact" />`,
|
|
2250
2700
|
` <zeebe:output ${attr("source", "=if (is defined(note)) then note else null")} target="humanNote" />`,
|
|
2701
|
+
// The completion-gate validity flag — computed from the SAME `selectExpr` selection the emit uses, so
|
|
2702
|
+
// it is independent of the (already fail-closed) coerced `humanEmitValue`/`humanEmitArtifact` above and
|
|
2703
|
+
// routes the gateway.
|
|
2704
|
+
...(gateRequired ? [` <zeebe:output ${attr("source", validitySource)} target="${flagVar}" />`] : []),
|
|
2251
2705
|
" </zeebe:ioMapping>",
|
|
2252
2706
|
" </bpmn:extensionElements>",
|
|
2253
2707
|
` <bpmn:incoming>${el}_i0</bpmn:incoming>`,
|
|
2708
|
+
// The invalid completion loops back to re-enter this task (re-park for a value correction).
|
|
2709
|
+
...(gateRequired ? [` <bpmn:incoming>${el}_cbad</bpmn:incoming>`] : []),
|
|
2254
2710
|
` <bpmn:outgoing>${el}_i1</bpmn:outgoing>`,
|
|
2255
2711
|
" </bpmn:userTask>",
|
|
2256
2712
|
` <bpmn:boundaryEvent id="${el}_sla" name="SLA elapsed" attachedToRef="${task}">`,
|
|
2257
2713
|
` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
|
|
2258
2714
|
` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
|
|
2259
2715
|
" </bpmn:boundaryEvent>",
|
|
2260
|
-
|
|
2716
|
+
endEvent,
|
|
2261
2717
|
` <bpmn:endEvent id="${el}_escEnd" name="Escalated">`,
|
|
2262
2718
|
" <bpmn:extensionElements>",
|
|
2263
2719
|
` <zeebe:ioMapping><zeebe:input ${attr("source", '="escalated"')} target="humanOutcome" /></zeebe:ioMapping>`,
|
|
@@ -2265,8 +2721,10 @@ function humanBodyLines(el: string, displayName: string): string[] {
|
|
|
2265
2721
|
` <bpmn:incoming>${el}_i2</bpmn:incoming>`,
|
|
2266
2722
|
" </bpmn:endEvent>",
|
|
2267
2723
|
flow(`${el}_i0`, `${el}_start`, task),
|
|
2268
|
-
|
|
2724
|
+
// Valid completion routes task → gate → end; the gate's default (invalid) loops back to the task.
|
|
2725
|
+
flow(`${el}_i1`, task, gateRequired ? `${el}_cg` : `${el}_end`),
|
|
2269
2726
|
flow(`${el}_i2`, `${el}_sla`, `${el}_escEnd`),
|
|
2727
|
+
...completionGate,
|
|
2270
2728
|
];
|
|
2271
2729
|
}
|
|
2272
2730
|
|
|
@@ -2284,6 +2742,108 @@ function humanBodyLines(el: string, displayName: string): string[] {
|
|
|
2284
2742
|
* mapping a naive resume publishes `<el>_<fact> = null`, silently starving the downstream consumer.
|
|
2285
2743
|
* `opts.diagnosticInputs` seeds read-only probe context (issue #514 Defect A) onto the same task so the
|
|
2286
2744
|
* operator can see WHY the gate escalated (its last probe detail + observed candidate releases). */
|
|
2745
|
+
|
|
2746
|
+
/** The ONE canonical runtime normalization a captured escalation/human-form text value is folded
|
|
2747
|
+
* through before a `boolean`/`number` gate or coercion reads it: `lower case(trim(string(...)))`. The
|
|
2748
|
+
* validity gate ({@link resumeValueCondition}) and the typed bind ({@link coerceFactValueFeel}) MUST
|
|
2749
|
+
* share it, or a value one accepts the other converts differently — e.g. `" true "` passes a trimming
|
|
2750
|
+
* gate but a non-trimming coercion publishes the wrong boolean, and `"TRUE"` a case-folding coercion
|
|
2751
|
+
* accepts but a non-folding gate rejects (#863 review r4194186383). Deriving both from this single
|
|
2752
|
+
* expression makes "gate accepts ⇒ coercion binds the same value" structurally true, not coincidental. */
|
|
2753
|
+
function normalizedBoolNumberFeel(rawExpr: string): string {
|
|
2754
|
+
return `lower case(trim(string(${rawExpr})))`;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** Coerce/validate a captured form value — a null-safe FEEL expression `rawExpr` yielding the operator's
|
|
2758
|
+
* entry (string-valued text, or null when absent) — to a single emit's declared {@link DeliveryFact.type},
|
|
2759
|
+
* so the published fact is a REAL FEEL value and not an untyped string. One source of truth shared by the
|
|
2760
|
+
* escalation-resume validation gate (the {@link escalationTaskLines} output mapping, which coerces the
|
|
2761
|
+
* resumed `value` when `resumeValueCondition` accepts it) and the bespoke single-emit human-form
|
|
2762
|
+
* source ({@link humanBodyLines}), so the two typed-publish paths cannot drift (PR #863 threads
|
|
2763
|
+
* r4182488193 / r4189815989). Returns a bare expression WITHOUT a leading `=`.
|
|
2764
|
+
* • `boolean` — accept the literal `"true"`/`"false"`, case-insensitively AND
|
|
2765
|
+
* surrounding-whitespace-trimmed via the shared {@link normalizedBoolNumberFeel}
|
|
2766
|
+
* (`lower case(trim(string(…)))`); anything else is invalid and yields null. (The pinned engine's
|
|
2767
|
+
* FEEL `matches` does not support an inline `(?i:…)` flag — verified against the WASM engine — so
|
|
2768
|
+
* case-insensitivity is done by lower-casing the input, not the pattern.) The validity gate
|
|
2769
|
+
* ({@link resumeValueCondition}) folds the SAME normalization, so a value it accepts coerces to the
|
|
2770
|
+
* SAME boolean here (#863 review r4194186383). An already-boolean control value round-trips
|
|
2771
|
+
* correctly: `string(true)` → `"true"` passes the same gate.
|
|
2772
|
+
* • `number` — `number(trim(string(value)))` parses the trimmed numeric text (matching the gate's
|
|
2773
|
+
* `trim(string(…))`); an unparseable entry yields null.
|
|
2774
|
+
* • every other type (`string`/`url`/`version`/`pr`/`artifact`) is text-valued already — pass through.
|
|
2775
|
+
* The defined FAILURE PATH for invalid input is `null`, so a required-emit producer gate escalates and a
|
|
2776
|
+
* guarded split takes its deadlock-safe default rather than routing on a mistyped value. `definedGuard`,
|
|
2777
|
+
* when given, wraps the passthrough/branch in an outer `is defined(...)` for a raw name that may be
|
|
2778
|
+
* entirely absent (the escalation `value` control); a `rawExpr` already null-safe (the human-form
|
|
2779
|
+
* selection expression) passes none. */
|
|
2780
|
+
function coerceFactValueFeel(fact: DeliveryFact, rawExpr: string, definedGuard?: string): string {
|
|
2781
|
+
const pre = definedGuard ? `${definedGuard} and ` : "";
|
|
2782
|
+
// Parenthesise rawExpr wherever it stands as a BARE infix operand (`(rawExpr) != null`): a caller may
|
|
2783
|
+
// pass a COMPOUND `if … then … else null` selection expression (the bespoke/generic human-form
|
|
2784
|
+
// `selectExpr`), and FEEL's `else` arm is GREEDY — an unparenthesised `if C then x else null != null
|
|
2785
|
+
// and matches(…)` parses as `if C then x else (null != null and matches(…))`, so when the field is
|
|
2786
|
+
// defined the whole coercion guard collapses to the raw entry and `if ("true") then … else null`
|
|
2787
|
+
// (a non-boolean condition) publishes NULL, silently defeating the coercion for every bespoke/generic
|
|
2788
|
+
// boolean & number human form (the escalation path passes the bare name `value`, so it was unaffected —
|
|
2789
|
+
// which is why the string-shape unit tests never caught it; empirically confirmed on the WASM engine).
|
|
2790
|
+
// The `string(rawExpr)`/`number(rawExpr)` sites are already bounded by their call parens, and the
|
|
2791
|
+
// passthrough `then rawExpr else null` has no trailing operator, so only the `!= null` operand needs it.
|
|
2792
|
+
const operand = `(${rawExpr})`;
|
|
2793
|
+
switch (fact.type) {
|
|
2794
|
+
case "boolean": {
|
|
2795
|
+
// Share the gate's EXACT normalization ({@link normalizedBoolNumberFeel}) so a value the validity
|
|
2796
|
+
// gate ({@link resumeValueCondition}) accepts coerces to the SAME boolean here — `" true "`/`"TRUE"`
|
|
2797
|
+
// can never be accepted-then-mis-published (#863 review r4194186383).
|
|
2798
|
+
const norm = normalizedBoolNumberFeel(rawExpr);
|
|
2799
|
+
// NULL-SAFE the normalization exactly like the number arm below: `operand != null` does NOT prove the
|
|
2800
|
+
// operand is STRINGABLE, so a fact name colliding with a FEEL builtin (`count`, …) left blank resolves
|
|
2801
|
+
// the selection operand to the builtin FUNCTION and `lower case(trim(string(<function>)))` throws a
|
|
2802
|
+
// runtime INCIDENT. Guard with `string(rawExpr) != null` (null-safe, short-circuits the normalization).
|
|
2803
|
+
return `if (${pre}${operand} != null and string(${rawExpr}) != null and matches(${norm}, "^(true|false)$")) then ${norm} = "true" else null`;
|
|
2804
|
+
}
|
|
2805
|
+
case "number":
|
|
2806
|
+
// TRIM/stringify to match the gate's `trim(string(...))` (#863 review r4194186383): the gate accepts
|
|
2807
|
+
// `" 42 "`, so the bind must parse the same trimmed text — a bare `number(" 42 ")` yields null,
|
|
2808
|
+
// breaking the "gate accepts ⇒ bind is non-null" invariant.
|
|
2809
|
+
// NULL-SAFE the trim: `string(rawExpr) != null` must guard it, because `operand != null` does NOT
|
|
2810
|
+
// prove the operand is STRINGABLE. When the fact name collides with a FEEL builtin (`count`, `sum`,
|
|
2811
|
+
// …), a blank human form leaves the variable unset, so the `is defined(count) and count != null`
|
|
2812
|
+
// selection operand resolves to the builtin FUNCTION (is-defined ⇒ true, function ≠ null), and
|
|
2813
|
+
// `trim(string(<function>))` throws `trim: expected a string, got null` — a runtime INCIDENT rather
|
|
2814
|
+
// than the intended null (the pre-round `number(rawExpr)` folded it to null; this restores that
|
|
2815
|
+
// null-safety while keeping the trim). `string(…)` itself is null-safe (yields null, no incident),
|
|
2816
|
+
// so the guard evaluates cleanly and short-circuits the trim away for any non-stringable operand.
|
|
2817
|
+
return `if (${pre}${operand} != null and string(${rawExpr}) != null) then number(trim(string(${rawExpr}))) else null`;
|
|
2818
|
+
case "string":
|
|
2819
|
+
case "version":
|
|
2820
|
+
case "url":
|
|
2821
|
+
case "pr": {
|
|
2822
|
+
// TEXT-VALUED types were previously passed through UNCHANGED — so a human form could publish a blank
|
|
2823
|
+
// string, a malformed version/URL, or an invalid PR ref directly downstream despite the declared
|
|
2824
|
+
// fact type (PR #863 review 5430718031, "Validate human-task fact values before publishing"). Fold
|
|
2825
|
+
// the SAME type-aware validity grammar the escalation resume gate ({@link resumeValueCondition})
|
|
2826
|
+
// enforces into this single `if`, failing CLOSED to null on a non-conforming entry. The grammar is
|
|
2827
|
+
// applied as a FLAT `matches(...)`/`trim(...)` on `string(rawExpr)` (null-safe, no
|
|
2828
|
+
// `is defined(<nested if…else>)` — the pinned engine mis-evaluates that compound inside an outer
|
|
2829
|
+
// guard, verified engine-native). The valid arm publishes the selection VERBATIM (these types need
|
|
2830
|
+
// no coercion), so a real captured value round-trips unchanged.
|
|
2831
|
+
const s = `string(${rawExpr})`;
|
|
2832
|
+
const grammar =
|
|
2833
|
+
fact.type === "string"
|
|
2834
|
+
? `trim(${s}) != ""`
|
|
2835
|
+
: fact.type === "version"
|
|
2836
|
+
? `matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true`
|
|
2837
|
+
: fact.type === "url"
|
|
2838
|
+
? `matches(trim(${s}), "^[A-Za-z][A-Za-z0-9+.-]*://") = true`
|
|
2839
|
+
: `matches(lower case(trim(${s})), "^(([^/#]+/[^/#]+)#(\\\\d+)|((https?://)?(www\\\\.)?github\\\\.com/[^/]+/[^/]+/pull/\\\\d+([/?#].*)?))$") = true`;
|
|
2840
|
+
return `if (${pre}${operand} != null and ${s} != null and ${grammar}) then ${rawExpr} else null`;
|
|
2841
|
+
}
|
|
2842
|
+
default:
|
|
2843
|
+
return definedGuard ? `if (${definedGuard}) then ${rawExpr} else null` : rawExpr;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2287
2847
|
function escalationTaskLines(
|
|
2288
2848
|
esc: string,
|
|
2289
2849
|
nodeId: string,
|
|
@@ -2291,11 +2851,22 @@ function escalationTaskLines(
|
|
|
2291
2851
|
outgoing: string,
|
|
2292
2852
|
contextFeel: string,
|
|
2293
2853
|
opts?: {
|
|
2294
|
-
|
|
2854
|
+
/** `emits` are the escalation's RESUME targets (the facts a Continue value may publish); for a
|
|
2855
|
+
* `wait` node this is the node's full declared-emit set, but for an `agent`/`connector` it is only
|
|
2856
|
+
* the REQUIRED subset a downstream node binds. `declaredEmits` is the node's FULL declared-emit set
|
|
2857
|
+
* — it drives SPEC 13.3's hard cardinality rule (value-resume ONLY when exactly one emit is
|
|
2858
|
+
* declared), which the required-count alone cannot see (issue #863 review, thread r4193902690).
|
|
2859
|
+
* Defaults to `emits` for the `wait` case where the two sets coincide. */
|
|
2860
|
+
resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[]; declaredEmits?: readonly DeliveryFact[] };
|
|
2295
2861
|
diagnosticInputs?: readonly { source: string; target: string }[];
|
|
2296
2862
|
displayName?: string;
|
|
2863
|
+
/** The node element whose retry gate this escalation feeds (#863): the completion publishes the
|
|
2864
|
+
* node-unique `<el>_retryRequested` boolean from the node-local `decision`, and the tail grows a
|
|
2865
|
+
* retry gate (`${esc}Rg`) routing Retry to the shared reset `${retryElement}_retry`. Absent for a
|
|
2866
|
+
* `wait`-gate escalation (resolution is "supply the awaited value", never "re-run the probe loop"). */
|
|
2867
|
+
retryElement?: string;
|
|
2297
2868
|
/** The element the resume-validation gate's VALID branch flows to (the node end). Required iff
|
|
2298
|
-
* `resume` carries emits — a resumable escalation never routes straight to its end. */
|
|
2869
|
+
* `resume` carries emits — a resumable escalation's Continue never routes straight to its end. */
|
|
2299
2870
|
validTarget?: string;
|
|
2300
2871
|
/** The emit-source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
|
|
2301
2872
|
* non-required (a routing-only `when`-guard emit, or a declared-but-unconsumed one) — that shares
|
|
@@ -2307,86 +2878,106 @@ function escalationTaskLines(
|
|
|
2307
2878
|
): string[] {
|
|
2308
2879
|
const resume = opts?.resume;
|
|
2309
2880
|
const emits = resume?.emits ?? [];
|
|
2310
|
-
|
|
2311
|
-
|
|
2881
|
+
// The node's FULL declared-emit set drives SPEC 13.3's hard cardinality rule. For a `wait` node
|
|
2882
|
+
// `resume.emits` already IS the declared set, so it defaults here; for an `agent`/`connector`
|
|
2883
|
+
// `resume.emits` is only the REQUIRED subset, so the declared set is threaded explicitly (issue #863
|
|
2884
|
+
// review, thread r4193902690).
|
|
2885
|
+
const declaredEmits = resume?.declaredEmits ?? emits;
|
|
2886
|
+
// The escalation form (`ESCALATION_FORM` / `GENERIC_HUMAN_FORM`) captures the operator's answer in a
|
|
2887
|
+
// SINGLE `value` field, so it can resume AT MOST ONE emit. With >1 required emit, one value cannot
|
|
2888
|
+
// satisfy multiple distinct typed facts — mapping it onto every emit-source var writes the SAME value
|
|
2889
|
+
// to each, corrupting all of them (and coercing one string into differently-typed facts; #863 review
|
|
2890
|
+
// r4180629319). So Continue-with-value is offered ONLY for a single emit; with multiple, the value
|
|
2891
|
+
// field is inert ("none"), the resume-valid flag is hard-`false` (#876 fail closed), and the
|
|
2892
|
+
// validation gate always re-parks — re-running the node ("Retry this step") stays the way to produce
|
|
2893
|
+
// the facts.
|
|
2894
|
+
//
|
|
2895
|
+
// The cardinality that gates this is the DECLARED-emit count, NOT the required-emit count: a node
|
|
2896
|
+
// declaring two facts where only one is required must STILL leave the value field inert (SPEC 13.3 —
|
|
2897
|
+
// "any node with multiple declared emits leaves the field inert"). Gating on `emits.length === 1`
|
|
2898
|
+
// (required) alone let such a node offer a value-resumable Continue that would publish the lone
|
|
2899
|
+
// required fact while silently dropping the sibling (issue #863 review, thread r4193902690).
|
|
2900
|
+
const resumableEmit = declaredEmits.length === 1 && emits.length === 1 ? emits[0] : undefined;
|
|
2901
|
+
const emitMode = resumableEmit !== undefined ? "typed" : "none";
|
|
2312
2902
|
const inputs: string[] = [
|
|
2313
2903
|
` <zeebe:input ${attr("source", contextFeel)} target="prompt" />`,
|
|
2314
2904
|
` <zeebe:input ${attr("source", `=${feelStr(nodeId)}`)} target="nodeId" />`,
|
|
2315
2905
|
` <zeebe:input ${attr("source", `=${feelStr(emitMode)}`)} target="emitMode" />`,
|
|
2316
2906
|
];
|
|
2317
|
-
if (
|
|
2318
|
-
inputs.push(
|
|
2907
|
+
if (resumableEmit !== undefined) {
|
|
2908
|
+
inputs.push(
|
|
2909
|
+
` <zeebe:input ${attr("source", `=${feelStr(`${resumableEmit.name} (${resumableEmit.type})`)}`)} target="emitLabel" />`,
|
|
2910
|
+
);
|
|
2319
2911
|
}
|
|
2320
2912
|
for (const di of opts?.diagnosticInputs ?? []) {
|
|
2321
2913
|
inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
|
|
2322
2914
|
}
|
|
2323
|
-
// Defect B + PR #876 review: the operator's captured typed value is VALIDATED on this
|
|
2324
|
-
// output mapping (reading the form's `value` field), which the pinned engine evaluates
|
|
2325
|
-
// unlike a downstream gateway's ioMapping, whose inputs/outputs are NOT visible downstream
|
|
2326
|
-
// engine-native). The required emit's emit-source var is bound from `value`
|
|
2327
|
-
//
|
|
2328
|
-
//
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
2331
|
-
// output ioMapping republishes downstream.
|
|
2332
|
-
//
|
|
2333
|
-
//
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2915
|
+
// Defect B + PR #876 review + #863 retry: the operator's captured typed value is VALIDATED on this
|
|
2916
|
+
// task's OWN output mapping (reading the form's `value` field), which the pinned engine evaluates
|
|
2917
|
+
// reliably — unlike a downstream gateway's ioMapping, whose inputs/outputs are NOT visible downstream
|
|
2918
|
+
// (verified engine-native). The required emit's emit-source var is bound from `value` — COERCED to
|
|
2919
|
+
// the fact's declared type ({@link coerceFactValueFeel}, #863) — ONLY when it is present and type-valid
|
|
2920
|
+
// ({@link resumeValueCondition}, #876), else null; a validity boolean flags the resume. The
|
|
2921
|
+
// post-escalation validation gateway routes on the SIMPLE `<resumeValidVar> = true` (a complex FEEL
|
|
2922
|
+
// condition on the gateway mis-evaluates — verified), re-parking an invalid resume (fail closed) so it
|
|
2923
|
+
// can never thread null/garbage onto the emit the subProcess output ioMapping republishes downstream.
|
|
2924
|
+
// On Continue (`decision != retry`) this validation gate runs; on Retry the per-escalation retry gate
|
|
2925
|
+
// funnels to the shared reset instead. The generic/service escalation form captures the answer in a
|
|
2926
|
+
// single `value` field — no `resolvedArtifact` field — so a single emit resumes from `value`,
|
|
2927
|
+
// validated per its fact type (artifact→resolvedArtifact, version→detail, …).
|
|
2336
2928
|
//
|
|
2337
|
-
// MULTI-EMIT (PR #876 review): the
|
|
2338
|
-
//
|
|
2339
|
-
//
|
|
2340
|
-
//
|
|
2341
|
-
// always loops back (the operator re-parks) and no single value is ever threaded onto several
|
|
2342
|
-
// distinct emits. (Per-fact resume would need a per-fact form, which the generic form does not have.)
|
|
2929
|
+
// MULTI-EMIT (PR #876 review): the form's SINGLE `value` cannot supply a distinct value per fact, so a
|
|
2930
|
+
// node owing more than one required emit CANNOT be resumed from it. Copying the one `value` into every
|
|
2931
|
+
// emit-source var would release duplicate/garbage facts downstream; instead we FAIL CLOSED — bind
|
|
2932
|
+
// nothing and hard-set the validity flag to `false`, so the validation gate always re-parks.
|
|
2343
2933
|
const outputs: string[] = [];
|
|
2344
|
-
// The resume-valid flag shares this escalation's flat engine scope with EVERY declared emit's
|
|
2345
|
-
//
|
|
2346
|
-
//
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
// output mapping from its own source var (see the `normaliseEmits(node)` output loop). So a node can
|
|
2351
|
-
// legally declare such an emit named EXACTLY the generated flag; left colliding, the escalation would
|
|
2352
|
-
// write its boolean validity flag into that emit's var and the subProcess would publish `true`/`false`
|
|
2353
|
-
// for it downstream (potentially selecting a guarded branch — PR #876 review). Reserve the source var
|
|
2354
|
-
// of every declared emit (callers thread them via `reservedTargets`) AND the resumed required emits,
|
|
2355
|
-
// then grow the flag with a deterministic unused `_` suffix until it differs from ALL of them —
|
|
2356
|
-
// closing the whole collision class structurally WITHOUT reserving any part of the public fact-name
|
|
2357
|
-
// space. `_` keeps it a legal FEEL identifier; the loop terminates because each step lengthens the
|
|
2358
|
-
// flag past every fixed-length reserved target.
|
|
2359
|
-
const reservedTargets = new Set<string>(opts?.reservedTargets ?? []);
|
|
2360
|
-
if (resume !== undefined) for (const f of emits) reservedTargets.add(factSourceVar(resume.kind, f));
|
|
2361
|
-
let flagVar = resume !== undefined && emits.length > 0 ? resumeValidVar(esc) : "";
|
|
2362
|
-
while (flagVar !== "" && reservedTargets.has(flagVar)) flagVar = `${flagVar}_`;
|
|
2363
|
-
if (resume !== undefined && emits.length === 1) {
|
|
2364
|
-
const fact = emits[0];
|
|
2934
|
+
// The resume-valid flag shares this escalation's flat engine scope with EVERY declared emit's source
|
|
2935
|
+
// var — not only the single resumed required emit — so it is grown collision-free against ALL of them
|
|
2936
|
+
// (shared with the reset via {@link escalationResumeFlagVar}). See that helper for the collision class.
|
|
2937
|
+
const flagVar = escalationResumeFlagVar(esc, resume, opts?.reservedTargets ?? []);
|
|
2938
|
+
if (resume !== undefined && resumableEmit !== undefined) {
|
|
2939
|
+
const fact = resumableEmit;
|
|
2365
2940
|
const target = factSourceVar(resume.kind, fact);
|
|
2941
|
+
const valid = resumeValueCondition(fact, "value");
|
|
2366
2942
|
outputs.push(
|
|
2367
|
-
|
|
2943
|
+
// Bind the coerced typed value only when it passes the fail-closed validity test (so flag=true
|
|
2944
|
+
// always implies a non-null, correctly-typed bind — the two can never disagree).
|
|
2945
|
+
` <zeebe:output ${attr("source", `=if ${valid} then ${coerceFactValueFeel(fact, "value")} else null`)} target="${target}" />`,
|
|
2368
2946
|
);
|
|
2369
2947
|
outputs.push(
|
|
2370
2948
|
// The validity flag the post-escalation gateway routes on — an internal, compiler-generated
|
|
2371
2949
|
// variable name ({@link resumeValidVar}), made collision-free against the emit-source var above
|
|
2372
2950
|
// by construction, so it can neither collide with nor be shadowed by a declared emit.
|
|
2373
|
-
` <zeebe:output ${attr("source", `=if ${
|
|
2951
|
+
` <zeebe:output ${attr("source", `=if ${valid} then true else false`)} target="${flagVar}" />`,
|
|
2374
2952
|
);
|
|
2375
|
-
} else if (resume !== undefined && emits.length >
|
|
2953
|
+
} else if (resume !== undefined && emits.length > 0) {
|
|
2376
2954
|
outputs.push(
|
|
2377
|
-
// Fail closed: more than one required emit
|
|
2378
|
-
//
|
|
2955
|
+
// Fail closed: the escalation is not value-resumable (more than one required emit, OR more than
|
|
2956
|
+
// one DECLARED emit so the single value field is inert per SPEC 13.3) yet a required emit is owed,
|
|
2957
|
+
// so the resume is never valid and the validation gate always re-parks (see the comment above).
|
|
2379
2958
|
` <zeebe:output ${attr("source", "=false")} target="${flagVar}" />`,
|
|
2380
2959
|
);
|
|
2381
2960
|
}
|
|
2961
|
+
if (opts?.retryElement !== undefined) {
|
|
2962
|
+
outputs.push(
|
|
2963
|
+
// The node-unique `<el>_retryRequested` boolean the per-escalation retry gate routes on (#863),
|
|
2964
|
+
// derived from the node-local `decision` the operator chose on `ESCALATION_FORM`.
|
|
2965
|
+
` <zeebe:output ${attr("source", `=is defined(${ESCALATION_DECISION_VAR}) and ${ESCALATION_DECISION_VAR} = ${feelStr(ESCALATION_DECISION_RETRY)}`)} target="${retryRequestedVar(opts.retryElement, opts.reservedTargets ?? [])}" />`,
|
|
2966
|
+
);
|
|
2967
|
+
}
|
|
2382
2968
|
// The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
|
|
2383
2969
|
// timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
|
|
2384
2970
|
// `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
|
|
2385
2971
|
const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
|
|
2972
|
+
// The retry-capable `decision` select lives ONLY on the service-escalation form (`ESCALATION_FORM`).
|
|
2973
|
+
// A wait-gate escalation carries no `retryElement` — its resolution is "supply the awaited value and
|
|
2974
|
+
// continue", never "re-run the probe loop" — so it keeps the select-less generic form rather than
|
|
2975
|
+
// render a "Retry this step" option that would be silently ignored.
|
|
2976
|
+
const form = opts?.retryElement !== undefined ? ESCALATION_FORM : GENERIC_HUMAN_FORM;
|
|
2386
2977
|
const task = [
|
|
2387
2978
|
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
|
|
2388
2979
|
" <bpmn:extensionElements>",
|
|
2389
|
-
` <zeebe:formDefinition formId="${
|
|
2980
|
+
` <zeebe:formDefinition formId="${form}" />`,
|
|
2390
2981
|
" <zeebe:userTask />",
|
|
2391
2982
|
' <zeebe:assignmentDefinition candidateGroups="operators" />',
|
|
2392
2983
|
" <zeebe:ioMapping>",
|
|
@@ -2398,35 +2989,74 @@ function escalationTaskLines(
|
|
|
2398
2989
|
` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
|
|
2399
2990
|
" </bpmn:userTask>",
|
|
2400
2991
|
];
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
//
|
|
2404
|
-
//
|
|
2405
|
-
//
|
|
2406
|
-
//
|
|
2407
|
-
//
|
|
2408
|
-
//
|
|
2409
|
-
//
|
|
2410
|
-
//
|
|
2411
|
-
//
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2992
|
+
const retryEl = opts?.retryElement;
|
|
2993
|
+
// The resume-validation gate re-parks an invalid Continue back onto THIS escalation task (fail
|
|
2994
|
+
// closed). That loop is only escapable when the operator has a way out of it — EITHER a genuinely
|
|
2995
|
+
// value-resumable single emit (supply the value → flag true → proceed to the node end) OR a Retry
|
|
2996
|
+
// gate (service nodes). A multi-emit WAIT escalation has NEITHER: the single `value` field cannot
|
|
2997
|
+
// supply >1 distinct fact so the flag is hard-`false` and Continue ALWAYS re-parks, and a wait gate
|
|
2998
|
+
// carries no `retryElement`, so there is no Retry escape. Building the gate there wedges the timed-out
|
|
2999
|
+
// token in an unresolvable loop (issue #863 review, thread r4198123619 — "Prevent unresolvable loops
|
|
3000
|
+
// for multi-emit wait nodes"). So the gate is grown ONLY when there is an escape; otherwise the
|
|
3001
|
+
// escalation flows straight to the node end, exactly like a no-emit wait escalation (an acknowledged,
|
|
3002
|
+
// value-less continue). Service nodes KEEP the fail-closed re-park — their Retry gate is the escape.
|
|
3003
|
+
const resumable = resume !== undefined && emits.length > 0 && (resumableEmit !== undefined || retryEl !== undefined);
|
|
3004
|
+
// A wait-gate escalation with no escapable resume (no `retryElement`, not resumable) has no tail — the
|
|
3005
|
+
// caller flows `outgoing` straight to the node end.
|
|
3006
|
+
if (retryEl === undefined && !resumable) return task;
|
|
3007
|
+
|
|
3008
|
+
const tail: string[] = [];
|
|
3009
|
+
// #863 retry gate (service escalations only): the esc task's `outgoing` flows into this gate (built by
|
|
3010
|
+
// the caller). Retry (`decision == retry`) → the SHARED reset `${retryEl}_retry`; Continue (default) →
|
|
3011
|
+
// the validation gate (if resumable) else the node end. Checking Retry FIRST is essential: a Retry
|
|
3012
|
+
// supplies no `value`, so its resume-valid flag is false — a validation-first topology would wrongly
|
|
3013
|
+
// re-park a legitimate Retry.
|
|
3014
|
+
if (retryEl !== undefined) {
|
|
3015
|
+
const rg = `${esc}Rg`;
|
|
3016
|
+
const rr = `${esc}Rr`;
|
|
3017
|
+
const rc = `${esc}Rc`;
|
|
3018
|
+
const continueTarget = resumable ? `${esc}Vg` : `${retryEl}_end`;
|
|
3019
|
+
tail.push(
|
|
3020
|
+
` <bpmn:exclusiveGateway id="${rg}" name="retry node?" default="${rc}">`,
|
|
3021
|
+
` <bpmn:incoming>${outgoing}</bpmn:incoming>`,
|
|
3022
|
+
` <bpmn:outgoing>${rr}</bpmn:outgoing>`,
|
|
3023
|
+
` <bpmn:outgoing>${rc}</bpmn:outgoing>`,
|
|
3024
|
+
" </bpmn:exclusiveGateway>",
|
|
3025
|
+
` <bpmn:sequenceFlow id="${rr}" name="retry" sourceRef="${rg}" targetRef="${retryEl}_retry"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${retryRequestedVar(retryEl, opts?.reservedTargets ?? [])} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
3026
|
+
` <bpmn:sequenceFlow id="${rc}" name="continue" sourceRef="${rg}" targetRef="${continueTarget}" />`,
|
|
3027
|
+
);
|
|
2416
3028
|
}
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
3029
|
+
|
|
3030
|
+
// Resume-validation gate (PR #876 review): a resumable Continue no longer routes straight to the node
|
|
3031
|
+
// end. It flows into an exclusive gateway whose VALID branch proceeds to the node end and whose
|
|
3032
|
+
// DEFAULT (invalid) branch loops back onto the escalation task — so a blank/malformed `value` re-parks
|
|
3033
|
+
// instead of releasing a null/invalid fact downstream (fail closed on the default). The gateway is a
|
|
3034
|
+
// PLAIN exclusive gateway (NO ioMapping — a gateway's ioMapping is not visible downstream in the
|
|
3035
|
+
// pinned engine, verified): the per-emit validation already ran on the escalation task's OWN output
|
|
3036
|
+
// mapping (binding the emit-source var + the validity flag), so the gateway only routes on the simple
|
|
3037
|
+
// `<resumeValidVar> = true` boolean. For a multi-emit node the flag is hard-`false` (unresumable via
|
|
3038
|
+
// the single-value form), so the gate always re-parks. The gate's incoming is the retry gate's
|
|
3039
|
+
// `continue` flow (service escalation) or the esc task's `outgoing` (wait escalation).
|
|
3040
|
+
if (resumable) {
|
|
3041
|
+
const validTarget = opts?.validTarget;
|
|
3042
|
+
if (validTarget === undefined) {
|
|
3043
|
+
throw new Error(`escalationTaskLines(${esc}): a resumable escalation requires opts.validTarget (the node end its valid resume flows to)`);
|
|
3044
|
+
}
|
|
3045
|
+
const vg = `${esc}Vg`;
|
|
3046
|
+
const vok = `${esc}Vok`;
|
|
3047
|
+
const vbad = `${esc}Vbad`;
|
|
3048
|
+
const vgIncoming = retryEl !== undefined ? `${esc}Rc` : outgoing;
|
|
3049
|
+
tail.push(
|
|
3050
|
+
` <bpmn:exclusiveGateway id="${vg}" name="resume value valid?" default="${vbad}">`,
|
|
3051
|
+
` <bpmn:incoming>${vgIncoming}</bpmn:incoming>`,
|
|
3052
|
+
` <bpmn:outgoing>${vok}</bpmn:outgoing>`,
|
|
3053
|
+
` <bpmn:outgoing>${vbad}</bpmn:outgoing>`,
|
|
3054
|
+
" </bpmn:exclusiveGateway>",
|
|
3055
|
+
` <bpmn:sequenceFlow id="${vok}" name="valid" sourceRef="${vg}" targetRef="${validTarget}"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
3056
|
+
` <bpmn:sequenceFlow id="${vbad}" name="invalid" sourceRef="${vg}" targetRef="${esc}" />`,
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
3059
|
+
return [...task, ...tail];
|
|
2430
3060
|
}
|
|
2431
3061
|
|
|
2432
3062
|
/** The per-required-emit FEEL condition an escalation resume value must satisfy to bind its
|
|
@@ -2474,16 +3104,28 @@ function escalationTaskLines(
|
|
|
2474
3104
|
* split differs from edge cases of the canonical parser.
|
|
2475
3105
|
* If these ever become common, the fix is to route the resume through the canonical host-side coercer
|
|
2476
3106
|
* (a completion-door redesign) rather than widening these regexes toward fail-OPEN. */
|
|
2477
|
-
function resumeValueCondition(fact: DeliveryFact, v: string): string {
|
|
2478
|
-
|
|
3107
|
+
function resumeValueCondition(fact: DeliveryFact, v: string, presentOverride?: string): string {
|
|
3108
|
+
// `presentOverride` lets a caller supply a FLAT null-safe presence test (`string(sel) != null`) when `v`
|
|
3109
|
+
// is a COMPOUND `if…then…else` selection (e.g. the human-form completion gate's `selectExpr`): the pinned
|
|
3110
|
+
// engine mis-evaluates `is defined(<compound if…then…else>)` — the same quirk `artifactSource` sidesteps
|
|
3111
|
+
// with a flat `matches(trim(string(…)))` — so the default `is defined(v)`-based presence is only safe for
|
|
3112
|
+
// a BARE variable name (the escalation resume `value`). Every per-type grammar arm below reads `v` only
|
|
3113
|
+
// through null-safe `string()`/`matches()`/`trim()` wrappers, so a flat presence override keeps the whole
|
|
3114
|
+
// condition engine-safe over a compound selection.
|
|
3115
|
+
const present = presentOverride ?? `((is defined(${v})) = true and (${v} != null))`;
|
|
2479
3116
|
const s = `string(${v})`;
|
|
2480
3117
|
switch (fact.type) {
|
|
2481
3118
|
case "string":
|
|
2482
3119
|
return `${present} and trim(${s}) != ""`;
|
|
2483
3120
|
case "number":
|
|
2484
|
-
|
|
3121
|
+
// Share the coercion's `number(trim(string(...)))` input ({@link normalizedBoolNumberFeel} folds an
|
|
3122
|
+
// extra harmless lower-case over the digits): gate and bind normalize identically (#863 r4194186383).
|
|
3123
|
+
return `${present} and (matches(${normalizedBoolNumberFeel(v)}, "^-?\\\\d+(\\\\.\\\\d+)?$") = true)`;
|
|
2485
3124
|
case "boolean":
|
|
2486
|
-
|
|
3125
|
+
// SAME normalization as the typed bind ({@link coerceFactValueFeel} → {@link normalizedBoolNumberFeel}),
|
|
3126
|
+
// so this gate accepts exactly the texts the coercion publishes correctly — `" true "`/`"TRUE"` are
|
|
3127
|
+
// accepted here AND bound as a real boolean, never accepted-then-mis-published (#863 r4194186383).
|
|
3128
|
+
return `${present} and (${normalizedBoolNumberFeel(v)} = "true" or ${normalizedBoolNumberFeel(v)} = "false")`;
|
|
2487
3129
|
case "version":
|
|
2488
3130
|
return `${present} and (matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true)`;
|
|
2489
3131
|
case "artifact":
|