@nanobpm/nano-workforce 0.201.0 → 0.201.1
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 +6 -0
- package/SPEC.md +153 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- 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/redactText.ts +88 -0
- package/app/service.ts +16 -2
- 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
|
@@ -15,6 +15,7 @@ import { test } from "node:test";
|
|
|
15
15
|
import { assert, assertEquals } from "#test-assert";
|
|
16
16
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
17
17
|
import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText, resumeValidVar } from "./deliveryGraphCompiler.ts";
|
|
18
|
+
import { isDeliveryEscalationTwin } from "./deliveryHuman.ts";
|
|
18
19
|
|
|
19
20
|
/** Compile and assert success, returning the narrowed ok-result. */
|
|
20
21
|
async function compileOk(graph: unknown) {
|
|
@@ -37,7 +38,7 @@ function humanTaskSubEl(bpmn: string): string {
|
|
|
37
38
|
const parts = bpmn.split('<bpmn:userTask id="delivery-human-task__');
|
|
38
39
|
for (let k = 1; k < parts.length; k++) {
|
|
39
40
|
const id = parts[k].slice(0, parts[k].indexOf('"'));
|
|
40
|
-
if (!
|
|
41
|
+
if (!isDeliveryEscalationTwin(`delivery-human-task__${id}`)) return id;
|
|
41
42
|
}
|
|
42
43
|
return "";
|
|
43
44
|
}
|
|
@@ -58,7 +59,7 @@ const RELEASE_RUNBOOK = {
|
|
|
58
59
|
{
|
|
59
60
|
id: "publish",
|
|
60
61
|
kind: "human",
|
|
61
|
-
human: { prompt: "run the manual OTP publish"
|
|
62
|
+
human: { prompt: "run the manual OTP publish" },
|
|
62
63
|
emits: [{ name: "resolvedArtifact", type: "artifact" }],
|
|
63
64
|
},
|
|
64
65
|
{ id: "consume", kind: "connector", connector: { target: "npm:install", dedupeKey: "consume-1" } },
|
|
@@ -98,10 +99,24 @@ test("#543 transcript correlation: only an agent node seeds transcriptUrlBase an
|
|
|
98
99
|
"an agent node propagates the worker-emitted transcriptUrl up to the instance scope",
|
|
99
100
|
);
|
|
100
101
|
// RELEASE_RUNBOOK has exactly ONE agent node — wait/human/connector must NOT carry the mapping.
|
|
102
|
+
// The agent node carries exactly TWO transcriptUrl outputs, BOTH agent-local: the propagate-out
|
|
103
|
+
// ioMapping (the `=if` source above) and the retry reset's `=null` clear (transcriptUrl is in
|
|
104
|
+
// AGENT_RESULT_LOCAL_VARS, so the retry reset wipes the previous attempt's value — PR #863). A
|
|
105
|
+
// non-agent kind carries neither, so splitting the count by source keeps that guarantee sharp.
|
|
101
106
|
assertEquals(
|
|
102
|
-
(r.bpmn.match(
|
|
107
|
+
(r.bpmn.match(/<zeebe:output source="=if \(is defined\(transcriptUrl\)\)[^>]*target="transcriptUrl"/g) ?? []).length,
|
|
103
108
|
1,
|
|
104
|
-
"only the agent node
|
|
109
|
+
"only the agent node propagates transcriptUrl out (non-agent kinds do not)",
|
|
110
|
+
);
|
|
111
|
+
assertEquals(
|
|
112
|
+
(r.bpmn.match(/<zeebe:output source="=null" target="transcriptUrl"/g) ?? []).length,
|
|
113
|
+
1,
|
|
114
|
+
"only the agent node's retry reset clears transcriptUrl (non-agent kinds have no reset for it)",
|
|
115
|
+
);
|
|
116
|
+
assertEquals(
|
|
117
|
+
(r.bpmn.match(/<zeebe:output [^>]*target="transcriptUrl"/g) ?? []).length,
|
|
118
|
+
2,
|
|
119
|
+
"transcriptUrl outputs are exactly the agent node's propagate-out + retry-reset clear",
|
|
105
120
|
);
|
|
106
121
|
assertEquals(
|
|
107
122
|
(r.bpmn.match(/target="transcriptUrlBase"/g) ?? []).length,
|
|
@@ -289,17 +304,23 @@ function elementForNode(bpmn: string, nodeId: string): string {
|
|
|
289
304
|
return m![1];
|
|
290
305
|
}
|
|
291
306
|
|
|
292
|
-
/** Slice a compiled BPMN to a node's escalation user task body.
|
|
307
|
+
/** Slice a compiled BPMN to a node's escalation user task body. A `wait` gate's twin carries the
|
|
308
|
+
* `__esc__wait` kind marker (no retry path → generic form); a service node's carries the bare `__esc`.
|
|
309
|
+
* Try the wait-twin id first, then the service-twin id. */
|
|
293
310
|
function escBlockForNode(bpmn: string, nodeId: string): string {
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
311
|
+
const el = elementForNode(bpmn, nodeId);
|
|
312
|
+
for (const suffix of ["esc__wait", "esc"]) {
|
|
313
|
+
const esc = `delivery-human-task__${el}__${suffix}`;
|
|
314
|
+
const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
|
|
315
|
+
if (start !== -1) return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
|
|
316
|
+
}
|
|
317
|
+
assert(false, `escalation task for node ${nodeId} exists`);
|
|
318
|
+
return "";
|
|
298
319
|
}
|
|
299
320
|
|
|
300
321
|
/** Slice a compiled BPMN to a node's escalation user task body by twin suffix (`esc` timeout or
|
|
301
322
|
* `contract` producer-gate, issue #731). */
|
|
302
|
-
function escBlockForNodeSuffix(bpmn: string, nodeId: string, suffix: "esc" | "contract"): string {
|
|
323
|
+
function escBlockForNodeSuffix(bpmn: string, nodeId: string, suffix: "esc" | "contract" | "esc__wait"): string {
|
|
303
324
|
const esc = `delivery-human-task__${elementForNode(bpmn, nodeId)}__${suffix}`;
|
|
304
325
|
const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
|
|
305
326
|
assert(start !== -1, `escalation task ${esc} for node ${nodeId} exists`);
|
|
@@ -344,7 +365,700 @@ test("#514 Defect B: a service-node escalation (agent) stays inert — no emit f
|
|
|
344
365
|
const r = await compileOk(CAP_GATE);
|
|
345
366
|
const esc = escBlockForNode(r.bpmn, "gv");
|
|
346
367
|
assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "an agent-node escalation keeps its emit field hidden");
|
|
347
|
-
assert(
|
|
368
|
+
assert(!/then value else null/.test(esc), "an emit-less agent-node escalation carries no emit-source output mapping");
|
|
369
|
+
// Its only output is the retry-node decision, published as the node-unique root boolean.
|
|
370
|
+
const el = elementForNode(r.bpmn, "gv");
|
|
371
|
+
assert(
|
|
372
|
+
esc.includes(`source='=is defined(decision) and decision = "retry"' target="${el}_retryRequested"`),
|
|
373
|
+
"the escalation publishes the retry decision for the node's retry gateway",
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// #863 review r4199435326: the node-unique ROOT controls the OUTER gateways route on — `<el>_contractMet`
|
|
378
|
+
// (producer-contract gate) and `<el>_retryRequested` (retry gate) — are decided INSIDE the node scope
|
|
379
|
+
// but must be published at root (Nano evaluates gateway conditions against root vars only). An agent
|
|
380
|
+
// emit's source var IS its own name, and every emit source var is null-seeded node-local by
|
|
381
|
+
// ioMappingLines; so an emit literally named `<el>_contractMet`/`<el>_retryRequested` would null-seed a
|
|
382
|
+
// LOCAL shadow of the control, and nearest-scope propagation would write the gate/escalation output into
|
|
383
|
+
// that shadow — the outer gateway never seeing `true`, wedging the producer forever. The control names
|
|
384
|
+
// must be grown collision-free against every node-local emit source var (as the resume-valid flag is).
|
|
385
|
+
// The single node here is the first-sorted element `n0`, so emits named `n0_contractMet`/`n0_retryRequested`
|
|
386
|
+
// reproduce the collision exactly.
|
|
387
|
+
test("#863 generated root controls grow collision-free against a same-named node-local emit (no shadow)", async () => {
|
|
388
|
+
const r = await compileOk({
|
|
389
|
+
name: "control-collision",
|
|
390
|
+
nodes: [
|
|
391
|
+
{
|
|
392
|
+
id: "open",
|
|
393
|
+
kind: "agent",
|
|
394
|
+
agent: { jobType: "senior:demo", prompt: "x" },
|
|
395
|
+
emits: [
|
|
396
|
+
{ name: "n0_contractMet", type: "boolean" },
|
|
397
|
+
{ name: "n0_retryRequested", type: "boolean" },
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
edges: [],
|
|
402
|
+
});
|
|
403
|
+
const el = elementForNode(r.bpmn, "open");
|
|
404
|
+
assertEquals(el, "n0", "the single node is the first-sorted element n0, so its emits collide with the bare control names");
|
|
405
|
+
// The colliding emit source vars ARE null-seeded node-local — this is the shadow the controls dodge.
|
|
406
|
+
assert(r.bpmn.includes('source="=null" target="n0_contractMet"'), "the colliding emit source var is null-seeded node-local");
|
|
407
|
+
assert(r.bpmn.includes('source="=null" target="n0_retryRequested"'), "the colliding retry emit source var is null-seeded node-local");
|
|
408
|
+
// Contract gate: the proceed output + gateway condition read the GROWN name, never the shadowed bare one.
|
|
409
|
+
assert(r.bpmn.includes('target="n0_contractMet_"'), "the contract proceed output targets the grown control var");
|
|
410
|
+
assert(r.bpmn.includes("=n0_contractMet_ = true"), "the producer-contract gateway reads the grown control var");
|
|
411
|
+
assert(!r.bpmn.includes("=n0_contractMet = true"), "the gateway must NOT read the shadowed bare control var");
|
|
412
|
+
// Retry gate (both escalations): the retry decision output + retry flow condition read the grown name.
|
|
413
|
+
assert(r.bpmn.includes('target="n0_retryRequested_"'), "the retry decision output targets the grown control var");
|
|
414
|
+
assert(r.bpmn.includes("=n0_retryRequested_ = true"), "the retry gateway reads the grown control var");
|
|
415
|
+
assert(!r.bpmn.includes("=n0_retryRequested = true"), "the retry gateway must NOT read the shadowed bare control var");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// The escalation form (`delivery-escalation.form` / generic) has a SINGLE `value` field, so it can
|
|
419
|
+
// resume AT MOST ONE emit. A node declaring >1 emit must NOT map that single value onto every emit
|
|
420
|
+
// source var (it would write the same value to each, corrupting both outputs and coercing one string
|
|
421
|
+
// into differently-typed facts — #863 review r4180629319). Continue is inert for multi-emit; Retry
|
|
422
|
+
// produces the facts.
|
|
423
|
+
const MULTI_EMIT = {
|
|
424
|
+
name: "multi-emit-required",
|
|
425
|
+
nodes: [
|
|
426
|
+
{
|
|
427
|
+
id: "open",
|
|
428
|
+
kind: "agent",
|
|
429
|
+
agent: { jobType: "senior:feature" },
|
|
430
|
+
emits: [
|
|
431
|
+
{ name: "pr", type: "pr" },
|
|
432
|
+
{ name: "mergedSha", type: "string" },
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr", sha: "open.mergedSha" }, dedupeKey: "land-1" } },
|
|
436
|
+
],
|
|
437
|
+
edges: [
|
|
438
|
+
{ from: "open.pr", to: "land" },
|
|
439
|
+
{ from: "open.mergedSha", to: "land" },
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
test("#863 multi-emit escalation is inert: a node declaring >1 emit cannot resume through the single value field (no cross-emit corruption)", async () => {
|
|
444
|
+
const r = await compileOk(MULTI_EMIT);
|
|
445
|
+
// The timeout escalation (`__esc`) carries the node's OWN declared emits — both would otherwise map
|
|
446
|
+
// from the one `value` field. With >1 emit the field is hidden ("none") and NO value→emit output is
|
|
447
|
+
// written, so Continue falls through to the downstream default (deadlock-safe) instead of corrupting
|
|
448
|
+
// both `pr` and `mergedSha` with the same operator string.
|
|
449
|
+
const escTimeout = escBlockForNodeSuffix(r.bpmn, "open", "esc");
|
|
450
|
+
assert(escTimeout.includes('="none"') && escTimeout.includes('target="emitMode"'), "a multi-emit timeout escalation hides its value field");
|
|
451
|
+
assert(!/then value else null/.test(escTimeout), "a multi-emit timeout escalation writes NO value→emit mapping (would corrupt both)");
|
|
452
|
+
assert(!/target="emitLabel"/.test(escTimeout), "a multi-emit timeout escalation emits no single emitLabel");
|
|
453
|
+
// The producer-contract escalation requires BOTH emits — it too must stay inert rather than resume
|
|
454
|
+
// both from one value.
|
|
455
|
+
const escContract = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
456
|
+
assert(escContract.includes('="none"') && escContract.includes('target="emitMode"'), "a multi-emit contract escalation hides its value field");
|
|
457
|
+
assert(!/then value else null/.test(escContract), "a multi-emit contract escalation writes NO value→emit mapping");
|
|
458
|
+
// The resolution hint must tell the operator Continue CANNOT resume (it re-parks, does not advance)
|
|
459
|
+
// and Retry produces the facts — never promise a fallback exit the hard-false flag makes unreachable
|
|
460
|
+
// (#863 review r4194186490).
|
|
461
|
+
assert(
|
|
462
|
+
escContract.includes("re-parks HERE") && escContract.includes("does NOT advance") && escContract.includes("Retry this step"),
|
|
463
|
+
"the hint steers a required multi-emit resolution to Retry and says Continue re-parks (does not advance)",
|
|
464
|
+
);
|
|
465
|
+
assert(!escContract.includes("fallback) branch"), "the hint must NOT promise a fallback exit for a required multi-emit escalation (Continue re-parks)");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// A node DECLARING two facts but where only ONE is a downstream data dependency (the sibling is
|
|
469
|
+
// declared-but-unconsumed). The resume boundary must gate on the DECLARED-emit count, not the required
|
|
470
|
+
// subset: SPEC 13.3 says "any node with multiple declared emits leaves the field inert". Gating on the
|
|
471
|
+
// required count alone (the #863 review r4193902690 gap) wrongly offered a value-resumable Continue
|
|
472
|
+
// that would publish the one required fact while silently dropping its declared sibling.
|
|
473
|
+
const TWO_DECLARED_ONE_REQUIRED = {
|
|
474
|
+
name: "two-declared-one-required",
|
|
475
|
+
nodes: [
|
|
476
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "open a PR" }, emits: [{ name: "pr", type: "pr" }, { name: "version", type: "version" }] },
|
|
477
|
+
// Only `open.pr` is consumed downstream, so `version` is declared-but-not-required.
|
|
478
|
+
{ id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" }, dedupeKey: "land-1" } },
|
|
479
|
+
],
|
|
480
|
+
edges: [{ from: "open.pr", to: "land" }],
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
test("#863 review r4193902690: a node declaring >1 emit is inert even when only ONE is required — resumability gates on DECLARED cardinality, not the required subset", async () => {
|
|
484
|
+
const r = await compileOk(TWO_DECLARED_ONE_REQUIRED);
|
|
485
|
+
// RED before the fix: `resumeEmits` (required) had length 1, so both escalations offered a typed
|
|
486
|
+
// value-resumable Continue that published `open.pr` from the single `value` field while dropping the
|
|
487
|
+
// declared-but-unrequired `version` — exactly the "multiple declared emits must leave the field inert"
|
|
488
|
+
// violation. GREEN: both twins are inert (emitMode="none", no value→emit mapping, flag hard-false).
|
|
489
|
+
for (const suffix of ["esc", "contract"] as const) {
|
|
490
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", suffix);
|
|
491
|
+
const flagVar = resumeValidVar(`delivery-human-task__${elementForNode(r.bpmn, "open")}__${suffix}`);
|
|
492
|
+
assert(esc.includes('="none"') && esc.includes('target="emitMode"'), `the __${suffix} escalation hides its value field (multiple declared emits)`);
|
|
493
|
+
assert(!/then value else null/.test(esc), `the __${suffix} escalation writes NO value→emit mapping`);
|
|
494
|
+
assert(!/target="pr" \/>/.test(esc), `the __${suffix} escalation binds NO pr value from the single form field`);
|
|
495
|
+
assert(!/target="emitLabel"/.test(esc), `the __${suffix} escalation emits no single emitLabel`);
|
|
496
|
+
assert(esc.includes(`<zeebe:output source="=false" target="${flagVar}" />`), `the __${suffix} escalation hard-sets its resume-valid flag to false (fail closed, always re-parks)`);
|
|
497
|
+
}
|
|
498
|
+
// The resolution hint must say Continue RE-PARKS (does not advance to a fallback branch), naming BOTH
|
|
499
|
+
// declared facts, never promise a value field OR a fallback exit the inert, hard-false escalation
|
|
500
|
+
// cannot take (#863 review r4194186490).
|
|
501
|
+
const contract = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
502
|
+
assert(
|
|
503
|
+
contract.includes("re-parks HERE") && contract.includes("does NOT advance") && contract.includes("Retry this step"),
|
|
504
|
+
"the hint steers a multi-declared resolution to Retry and says Continue re-parks (does not advance)",
|
|
505
|
+
);
|
|
506
|
+
assert(!contract.includes("fallback) branch"), "the hint must NOT promise a fallback exit for a multi-declared escalation owing a required emit");
|
|
507
|
+
assert(contract.includes("'pr'/'version'"), "the hint names BOTH declared facts the single value field cannot supply");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("#863 single-emit escalation still resumes: exactly one emit maps the operator value onto its source var", async () => {
|
|
511
|
+
// Guard the boundary: the multi-emit fix must NOT regress the single-emit resume path.
|
|
512
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
513
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
514
|
+
assert(esc.includes('="typed"') && esc.includes('target="emitMode"'), "a single-emit contract escalation PRESENTS its value field");
|
|
515
|
+
// The composed resume path (PR #876 validation nested on #863 retry) binds the single emit from the
|
|
516
|
+
// form's `value` onto its source var — guarded by the fail-closed validity test — so a valid Continue
|
|
517
|
+
// publishes it and a blank/invalid one re-parks.
|
|
518
|
+
const contractOut = esc.match(/<zeebe:output source=(["'])(.*?)\1 target="pr"/);
|
|
519
|
+
assert(contractOut, "the single emit resumes from the value field onto its source var (pr)");
|
|
520
|
+
const contractFeel = contractOut[2].replaceAll(""", '"');
|
|
521
|
+
assert(/then value else null/.test(contractFeel), `the resume binds the operator value onto the emit source, got: ${contractFeel}`);
|
|
522
|
+
assert(/is defined\(value\)/.test(contractFeel), "the resume guards on a present value (fail closed)");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("#863 typed Continue: a single-emit escalation coerces/validates the textfield `value` to the emit's declared type before publishing (r4182488193)", async () => {
|
|
526
|
+
// Regression guard (PR #863 Copilot High, thread r4182488193): the escalation form captures `value`
|
|
527
|
+
// from a TEXTFIELD (always a string), but the single-emit resume published it unchanged for every
|
|
528
|
+
// declared fact type. A Continue on a `boolean`/`number` emit therefore wrote the STRINGS
|
|
529
|
+
// `"true"`/`"1"`, while a downstream guarded split compares against typed FEEL literals
|
|
530
|
+
// (`= true`/`= 1`), so the guarded branch was skipped. The resume must coerce/validate using
|
|
531
|
+
// `resumableEmit.type` before writing the emit source, with a defined failure path (an unparseable
|
|
532
|
+
// entry publishes null → the required-emit gate / deadlock-safe default) for invalid input.
|
|
533
|
+
// The emit must be REQUIRED (consumed by a downstream node) for the timeout escalation to present a
|
|
534
|
+
// resumable value field (PR #872 / #876): a `use` consumer binds each producer's fact so its `__esc`
|
|
535
|
+
// escalation offers the typed Continue path whose coercion this test guards.
|
|
536
|
+
const boolGraph = await compileOk({
|
|
537
|
+
nodes: [
|
|
538
|
+
{ id: "gate", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "ok", type: "boolean" }] },
|
|
539
|
+
{ id: "use", kind: "agent", agent: { jobType: "j2" } },
|
|
540
|
+
],
|
|
541
|
+
edges: [{ from: "gate.ok", to: "use" }],
|
|
542
|
+
});
|
|
543
|
+
const boolEsc = escBlockForNodeSuffix(boolGraph.bpmn, "gate", "esc");
|
|
544
|
+
const boolOut = boolEsc.match(/<zeebe:output source='([^']*)' target="ok"/);
|
|
545
|
+
assert(boolOut, "the boolean emit's escalation resume maps value onto its source var");
|
|
546
|
+
const boolFeel = boolOut[1].replaceAll(""", '"');
|
|
547
|
+
assert(/lower case\(trim\(string\(value\)\)\)\s*=\s*"true"/.test(boolFeel), `a boolean emit coerces the trimmed/lower-cased text to a real boolean (… = "true"), got: ${boolFeel}`);
|
|
548
|
+
assert(/matches\(lower case\(trim\(string\(value\)\)\)/.test(boolFeel), "a boolean emit validates the normalized text is true/false before coercing");
|
|
549
|
+
assert(/else null/.test(boolFeel), "an unparseable boolean entry publishes null (the defined failure path), not the raw string");
|
|
550
|
+
|
|
551
|
+
const numGraph = await compileOk({
|
|
552
|
+
nodes: [
|
|
553
|
+
{ id: "n", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "count", type: "number" }] },
|
|
554
|
+
{ id: "use", kind: "agent", agent: { jobType: "j2" } },
|
|
555
|
+
],
|
|
556
|
+
edges: [{ from: "n.count", to: "use" }],
|
|
557
|
+
});
|
|
558
|
+
const numEsc = escBlockForNodeSuffix(numGraph.bpmn, "n", "esc");
|
|
559
|
+
const numOut = numEsc.match(/<zeebe:output source=(["'])([\s\S]*?)\1 target="count"/);
|
|
560
|
+
assert(numOut, "the number emit's escalation resume maps value onto its source var");
|
|
561
|
+
const numFeel = numOut[2].replaceAll(""", '"');
|
|
562
|
+
assert(/number\(trim\(string\(value\)\)\)/.test(numFeel), `a number emit coerces the trimmed text via number(trim(string(value))), got: ${numFeel}`);
|
|
563
|
+
assert(/else null/.test(numFeel), "an unparseable number entry publishes null");
|
|
564
|
+
|
|
565
|
+
// A string-typed emit passes the text through unchanged (no coercion needed) — but the composed
|
|
566
|
+
// resume path still guards it with the fail-closed validity test (PR #876), so the bind is
|
|
567
|
+
// `=if (<non-blank>) then value else null`, not an unconditional `value`.
|
|
568
|
+
const strGraph = await compileOk({
|
|
569
|
+
nodes: [
|
|
570
|
+
{ id: "s", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "verdict", type: "string" }] },
|
|
571
|
+
{ id: "use", kind: "agent", agent: { jobType: "j2" } },
|
|
572
|
+
],
|
|
573
|
+
edges: [{ from: "s.verdict", to: "use" }],
|
|
574
|
+
});
|
|
575
|
+
const strEsc = escBlockForNodeSuffix(strGraph.bpmn, "s", "esc");
|
|
576
|
+
const strOut = strEsc.match(/<zeebe:output source=(["'])(.*?)\1 target="verdict"/);
|
|
577
|
+
assert(strOut, "a string emit maps the value onto its source var");
|
|
578
|
+
const strFeel = strOut[2].replaceAll(""", '"');
|
|
579
|
+
assert(/then value else null/.test(strFeel), `a string emit passes the text value through unchanged (then value else null), got: ${strFeel}`);
|
|
580
|
+
assert(/is defined\(value\)/.test(strFeel), "a string emit still guards on a present, non-blank value (fail closed)");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("#863 review r4194186383: the boolean/number resume VALIDITY gate and the typed COERCION share ONE normalization — a value accepted can never be mis-published", async () => {
|
|
584
|
+
// RED before the fix: the validity gate trimmed (`trim(string(value))`) but the coercion did not
|
|
585
|
+
// (`lower case(string(value))`), so `\" true \"` passed the gate (validity=true) yet the coercion
|
|
586
|
+
// published the WRONG boolean, and `\"TRUE\"` the coercion accepted but the gate rejected. Both sides
|
|
587
|
+
// must now fold the SAME `lower case(trim(string(value)))`, so "gate accepts ⇒ bind is the same value".
|
|
588
|
+
const boolGraph = await compileOk({
|
|
589
|
+
nodes: [
|
|
590
|
+
{ id: "gate", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "ok", type: "boolean" }] },
|
|
591
|
+
{ id: "use", kind: "agent", agent: { jobType: "j2" } },
|
|
592
|
+
],
|
|
593
|
+
edges: [{ from: "gate.ok", to: "use" }],
|
|
594
|
+
});
|
|
595
|
+
const boolEsc = escBlockForNodeSuffix(boolGraph.bpmn, "gate", "esc");
|
|
596
|
+
const boolOut = boolEsc.match(/<zeebe:output source='([^']*)' target="ok"/);
|
|
597
|
+
assert(boolOut, "the boolean emit resume binds value onto its source var");
|
|
598
|
+
const boolFeel = boolOut![1].replaceAll(""", '"');
|
|
599
|
+
// The bind is `=if <gate> then <coerce> else null`: BOTH the gate (the matches/equality) and the
|
|
600
|
+
// coerce (the `= \"true\"`) read the trimmed+lower-cased text, and NO occurrence reads the old
|
|
601
|
+
// un-trimmed `lower case(string(value))` form.
|
|
602
|
+
assert(
|
|
603
|
+
(boolFeel.match(/lower case\(trim\(string\(value\)\)\)/g) ?? []).length >= 2,
|
|
604
|
+
`both the boolean gate and coercion fold lower case(trim(string(value))), got: ${boolFeel}`,
|
|
605
|
+
);
|
|
606
|
+
assert(!/lower case\(string\(value\)\)/.test(boolFeel), `no boolean side may read the un-trimmed lower case(string(value)), got: ${boolFeel}`);
|
|
607
|
+
|
|
608
|
+
const numGraph = await compileOk({
|
|
609
|
+
nodes: [
|
|
610
|
+
{ id: "n", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "count", type: "number" }] },
|
|
611
|
+
{ id: "use", kind: "agent", agent: { jobType: "j2" } },
|
|
612
|
+
],
|
|
613
|
+
edges: [{ from: "n.count", to: "use" }],
|
|
614
|
+
});
|
|
615
|
+
const numEsc = escBlockForNodeSuffix(numGraph.bpmn, "n", "esc");
|
|
616
|
+
const numOut = numEsc.match(/<zeebe:output source=(["'])([\s\S]*?)\1 target="count"/);
|
|
617
|
+
assert(numOut, "the number emit resume binds value onto its source var");
|
|
618
|
+
const numFeel = numOut![2].replaceAll(""", '"');
|
|
619
|
+
// The number gate matches the NORMALIZED text and the coercion parses the SAME trimmed text, so the
|
|
620
|
+
// gate can never accept `\" 42 \"` while `number(\" 42 \")` yields null.
|
|
621
|
+
assert(/matches\(lower case\(trim\(string\(value\)\)\)/.test(numFeel), `the number gate matches the normalized text, got: ${numFeel}`);
|
|
622
|
+
assert(/number\(trim\(string\(value\)\)\)/.test(numFeel), `the number coercion parses the SAME trimmed text, got: ${numFeel}`);
|
|
623
|
+
assert(!/number\(value\)/.test(numFeel), `the number coercion must not parse the un-trimmed raw value, got: ${numFeel}`);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("#863 review r4194186490: a multi-declared node with NO required resume target keeps the fallback-branch hint (Continue genuinely advances)", async () => {
|
|
627
|
+
// The hint promises a fallback exit ONLY when Continue can actually take it — i.e. the node has NO
|
|
628
|
+
// required resume target, so no validation gate re-parks it. A node declaring two emits that NO
|
|
629
|
+
// downstream node consumes is such a case: the escalation is not validation-gated, so Continue
|
|
630
|
+
// proceeds past the node. (Contrast the required-emit cases above, which must RE-PARK, not advance.)
|
|
631
|
+
const r = await compileOk({
|
|
632
|
+
name: "two-declared-none-required",
|
|
633
|
+
nodes: [
|
|
634
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:demo", prompt: "x" }, emits: [{ name: "pr", type: "pr" }, { name: "version", type: "version" }] },
|
|
635
|
+
{ id: "sink", kind: "agent", agent: { jobType: "j2", prompt: "y" } },
|
|
636
|
+
],
|
|
637
|
+
// `open` is sequenced before `sink` but neither of its facts is bound downstream (control-flow edge).
|
|
638
|
+
edges: [{ from: "open", to: "sink" }],
|
|
639
|
+
});
|
|
640
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", "esc");
|
|
641
|
+
assert(esc.includes("default (fallback) branch") && esc.includes("no downstream node requires"), "a multi-declared node with no required emit keeps the fallback-branch Continue hint");
|
|
642
|
+
assert(!esc.includes("re-parks HERE"), "a node with no required resume target does not re-park on Continue");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("#863 human multi-emit: a human node emitting several facts with no form is REJECTED (no static form can capture them → would publish null) (r4182488264 / thread deliveryGraphCompiler.ts:1859)", async () => {
|
|
646
|
+
// Regression guard (PR #863 Copilot High, thread deliveryGraphCompiler.ts:1859): a human form captures
|
|
647
|
+
// ONE value, and a human node has no Retry path — so a node emitting ≥2 facts with no explicit form
|
|
648
|
+
// can never capture the extra emits and would permanently schedule downstream consumers with null
|
|
649
|
+
// required facts. Reject the whole CLASS at authoring time (`human-unroutable-emits`) rather than
|
|
650
|
+
// silently discard emits.
|
|
651
|
+
const errors = await compileFail({
|
|
652
|
+
nodes: [
|
|
653
|
+
{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "a", type: "string" }, { name: "b", type: "string" }] },
|
|
654
|
+
],
|
|
655
|
+
edges: [],
|
|
656
|
+
});
|
|
657
|
+
const e = errors.find((err) => err.path === "nodes[0].emits" && /no static form can/.test(err.message));
|
|
658
|
+
assert(e !== undefined, `expected a human-unroutable-emits error, got ${JSON.stringify(errors)}`);
|
|
659
|
+
|
|
660
|
+
// An explicit `human.formKey` does NOT rescue a ≥2-emit node (PR #863 Copilot High, thread
|
|
661
|
+
// r4184443511): the compiled task ioMapping reads only the fixed value/resolvedArtifact/note controls,
|
|
662
|
+
// never a bespoke form's per-fact fields, so the extra emits would still publish null. Rejected too.
|
|
663
|
+
const explicitForm = await compileFail({
|
|
664
|
+
nodes: [
|
|
665
|
+
{ id: "h", kind: "human", human: { prompt: "do it", formKey: "bespoke-multi" }, emits: [{ name: "a", type: "string" }, { name: "b", type: "string" }] },
|
|
666
|
+
],
|
|
667
|
+
edges: [],
|
|
668
|
+
});
|
|
669
|
+
const ef = explicitForm.find((err) => err.path === "nodes[0].emits" && /no static form can/.test(err.message));
|
|
670
|
+
assert(ef !== undefined, `expected a human-unroutable-emits error for an explicit-formKey multi-emit node, got ${JSON.stringify(explicitForm)}`);
|
|
671
|
+
|
|
672
|
+
// …but a SINGLE non-artifact human emit still compiles and resumes from humanEmitValue (the rejection
|
|
673
|
+
// must not regress the single-emit path).
|
|
674
|
+
const single = await compileOk({
|
|
675
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }] }],
|
|
676
|
+
edges: [],
|
|
677
|
+
});
|
|
678
|
+
const sEl = elementForNode(single.bpmn, "h");
|
|
679
|
+
const sSub = single.bpmn.slice(single.bpmn.indexOf(`<bpmn:subProcess id="${sEl}"`));
|
|
680
|
+
assert(/<zeebe:output source="=if \(is defined\(humanEmitValue\)\) then humanEmitValue else null" target="[^"]+_verdict" \/>/.test(sSub), "a single-emit human node still publishes its one fact from humanEmitValue");
|
|
681
|
+
// The single value-emit node embeds the generic typed-emit form.
|
|
682
|
+
const sTask = sSub.slice(sSub.indexOf("<bpmn:userTask"), sSub.indexOf("</bpmn:userTask>"));
|
|
683
|
+
assert(sTask.includes('formId="delivery-human-generic"'), "a single value-emit human node embeds the generic form");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("#863 human mixed emits: one scalar + one artifact (no form) is REJECTED — no static form captures both (thread deliveryGraphCompiler.ts:1859)", async () => {
|
|
687
|
+
// Regression guard (PR #863 Copilot High): the generic form has no `resolvedArtifact` control and the
|
|
688
|
+
// publish form no `value` control, so a 1-scalar+1-artifact human node (two emits) has no static form
|
|
689
|
+
// that captures BOTH — it resolves to the agent-router and must be rejected, not compiled with a
|
|
690
|
+
// half-captured result. (A SINGLE artifact resolves to the publish form; see the artifact test below.)
|
|
691
|
+
const errors = await compileFail({
|
|
692
|
+
nodes: [
|
|
693
|
+
{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }, { name: "report", type: "artifact" }] },
|
|
694
|
+
],
|
|
695
|
+
edges: [],
|
|
696
|
+
});
|
|
697
|
+
const e = errors.find((err) => err.path === "nodes[0].emits" && /no static form can/.test(err.message));
|
|
698
|
+
assert(e !== undefined, `expected a human-unroutable-emits error, got ${JSON.stringify(errors)}`);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test("#863 human single artifact: a human node emitting ONE artifact embeds the manual-publish form (which captures resolvedArtifact) — thread deliveryGraphCompiler.ts:1859", async () => {
|
|
702
|
+
// Regression guard (PR #863 Copilot High): a single-artifact human node must embed the publish form so
|
|
703
|
+
// an operator actually has a `resolvedArtifact` control — the generic form has none, so it would
|
|
704
|
+
// publish null. The compiler now DERIVES the form from resolveHumanForm instead of hardcoding generic.
|
|
705
|
+
const r = await compileOk({
|
|
706
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "publish it" }, emits: [{ name: "art", type: "artifact" }] }],
|
|
707
|
+
edges: [],
|
|
708
|
+
});
|
|
709
|
+
const el = elementForNode(r.bpmn, "h");
|
|
710
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
711
|
+
const task = sub.slice(sub.indexOf("<bpmn:userTask"), sub.indexOf("</bpmn:userTask>"));
|
|
712
|
+
assert(task.includes('formId="delivery-human-publish"'), "a single-artifact human node embeds the manual-publish form (captures resolvedArtifact)");
|
|
713
|
+
// …and its artifact emit reads the distinct artifact capture.
|
|
714
|
+
const io = sub.slice(0, sub.indexOf("</bpmn:subProcess>"));
|
|
715
|
+
assert(/<zeebe:output source="=if \(is defined\(humanEmitArtifact\)\) then humanEmitArtifact else null" target="[^"]+_art" \/>/.test(io), "the artifact emit reads humanEmitArtifact");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("#863 human single-emit explicit form: the userTask reads the FACT-NAMED field, so a bespoke form keyed on the fact does not publish null (thread deliveryGraphCompiler.ts:1929)", async () => {
|
|
719
|
+
// Regression guard (PR #863 review — "Preserve fact-named outputs from custom human forms"): a
|
|
720
|
+
// single-emit human node MAY carry an explicit bespoke `formKey`; `resolveHumanForm` accepts it. The
|
|
721
|
+
// canonical binding contract `bindHumanEmits` reads the fact's OWN name first (then the generic
|
|
722
|
+
// `value`/`resolvedArtifact` keys), so a valid custom form returning `{ approval: "yes" }` for an
|
|
723
|
+
// `approval` emit is legitimate. The userTask ioMapping must therefore read that fact-named field
|
|
724
|
+
// (falling back to the canonical control) — otherwise it reads only `value`, which is undefined, and
|
|
725
|
+
// the emit publishes NULL despite a correctly-filled form.
|
|
726
|
+
const scalar = await compileOk({
|
|
727
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "bespoke-approval" }, emits: [{ name: "approval", type: "boolean" }] }],
|
|
728
|
+
edges: [],
|
|
729
|
+
});
|
|
730
|
+
const sEl = elementForNode(scalar.bpmn, "h");
|
|
731
|
+
const sSub = scalar.bpmn.slice(scalar.bpmn.indexOf(`<bpmn:subProcess id="${sEl}"`));
|
|
732
|
+
const sTask = sSub.slice(sSub.indexOf("<bpmn:userTask"), sSub.indexOf("</bpmn:userTask>"));
|
|
733
|
+
assert(sTask.includes('formId="bespoke-approval"'), "the explicit bespoke form is attached");
|
|
734
|
+
// The userTask value source must (a) read the fact-named `approval` field then fall back to `value`,
|
|
735
|
+
// AND (b) COERCE that selected text to the fact's declared `boolean` type before writing
|
|
736
|
+
// `humanEmitValue` — otherwise a bespoke form returning the string "true" publishes `"true"`, which the
|
|
737
|
+
// downstream guarded split (`= true`) never matches (PR #863 thread r4189815989). The coercion is the
|
|
738
|
+
// SAME typed-binding contract the escalation resume enforces.
|
|
739
|
+
const sValueOut = sTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/);
|
|
740
|
+
assert(sValueOut, "the bespoke boolean form maps a value onto humanEmitValue");
|
|
741
|
+
const sValueFeel = sValueOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
742
|
+
assert(
|
|
743
|
+
/is defined\(approval\) and approval != null and string\(approval\) != null\) then approval else if \(is defined\(value\)\) then value else null/.test(sValueFeel),
|
|
744
|
+
`the bespoke boolean form still reads the fact-named \`approval\` field (builtin-shadow-guarded) then \`value\`, got: ${sValueFeel}`,
|
|
745
|
+
);
|
|
746
|
+
assert(
|
|
747
|
+
/matches\(lower case\(trim\(string\(.*\)\)\), "\^\(true\|false\)\$"\)/.test(sValueFeel) && /= "true"/.test(sValueFeel),
|
|
748
|
+
`the selected bespoke boolean value is coerced/validated to a real FEEL boolean, got: ${sValueFeel}`,
|
|
749
|
+
);
|
|
750
|
+
assert(/else null/.test(sValueFeel), "an unparseable bespoke boolean entry publishes null (the defined failure path)");
|
|
751
|
+
|
|
752
|
+
// The class, not the instance: a bespoke single-NUMBER form must likewise coerce the selected text to a
|
|
753
|
+
// real FEEL number (the guarded split compares `= 1`, not `"1"`).
|
|
754
|
+
const numeric = await compileOk({
|
|
755
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "count", formKey: "bespoke-count" }, emits: [{ name: "tally", type: "number" }] }],
|
|
756
|
+
edges: [],
|
|
757
|
+
});
|
|
758
|
+
const nEl = elementForNode(numeric.bpmn, "h");
|
|
759
|
+
const nSub = numeric.bpmn.slice(numeric.bpmn.indexOf(`<bpmn:subProcess id="${nEl}"`));
|
|
760
|
+
const nTask = nSub.slice(nSub.indexOf("<bpmn:userTask"), nSub.indexOf("</bpmn:userTask>"));
|
|
761
|
+
const nValueOut = nTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/);
|
|
762
|
+
assert(nValueOut, "the bespoke number form maps a value onto humanEmitValue");
|
|
763
|
+
const nValueFeel = nValueOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
764
|
+
assert(
|
|
765
|
+
/is defined\(tally\) and tally != null and string\(tally\) != null\) then tally else if \(is defined\(value\)\) then value else null/.test(nValueFeel),
|
|
766
|
+
`the bespoke number form still reads the fact-named \`tally\` field (builtin-shadow-guarded) then \`value\`, got: ${nValueFeel}`,
|
|
767
|
+
);
|
|
768
|
+
assert(/number\(/.test(nValueFeel) && /else null/.test(nValueFeel), `the selected bespoke number value is coerced via number(…) with a null failure path, got: ${nValueFeel}`);
|
|
769
|
+
|
|
770
|
+
// The class, not the instance: the SAME gap exists for a single ARTIFACT emit with a bespoke form —
|
|
771
|
+
// the fact-named field must be preferred ahead of the canonical `resolvedArtifact` control.
|
|
772
|
+
const artifact = await compileOk({
|
|
773
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "publish", formKey: "bespoke-publish" }, emits: [{ name: "release", type: "artifact" }] }],
|
|
774
|
+
edges: [],
|
|
775
|
+
});
|
|
776
|
+
const aEl = elementForNode(artifact.bpmn, "h");
|
|
777
|
+
const aSub = artifact.bpmn.slice(artifact.bpmn.indexOf(`<bpmn:subProcess id="${aEl}"`));
|
|
778
|
+
const aTask = aSub.slice(aSub.indexOf("<bpmn:userTask"), aSub.indexOf("</bpmn:userTask>"));
|
|
779
|
+
const aOut = aTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitArtifact"/);
|
|
780
|
+
assert(aOut, "a bespoke single-artifact form maps a value onto humanEmitArtifact");
|
|
781
|
+
const aFeel = aOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
782
|
+
assert(
|
|
783
|
+
/is defined\(release\) and release != null and string\(release\) != null\) then release else if \(is defined\(resolvedArtifact\)\)/.test(aFeel),
|
|
784
|
+
`a bespoke single-artifact form still reads the fact-named \`release\` field (builtin-shadow-guarded) ahead of \`resolvedArtifact\`, got: ${aFeel}`,
|
|
785
|
+
);
|
|
786
|
+
assert(
|
|
787
|
+
/matches\(trim\(string\(/.test(aFeel) && /else null/.test(aFeel),
|
|
788
|
+
`a bespoke single-artifact form is now wrapped in the artifact-grammar validity gate (fail closed to null), got: ${aFeel}`,
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
// And the generic/publish forms (no explicit formKey) are UNAFFECTED — they still read the fixed
|
|
792
|
+
// `value`/`resolvedArtifact` controls (no fact-named preference leaks in to break them).
|
|
793
|
+
const generic = await compileOk({
|
|
794
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }] }],
|
|
795
|
+
edges: [],
|
|
796
|
+
});
|
|
797
|
+
const gEl = elementForNode(generic.bpmn, "h");
|
|
798
|
+
const gSub = generic.bpmn.slice(generic.bpmn.indexOf(`<bpmn:subProcess id="${gEl}"`));
|
|
799
|
+
const gTask = gSub.slice(gSub.indexOf("<bpmn:userTask"), gSub.indexOf("</bpmn:userTask>"));
|
|
800
|
+
// A generic single-VALUE node does NOT read a fact-named field (only an explicit bespoke form does), so
|
|
801
|
+
// it keeps the canonical `value` capture — now VALIDATED non-blank (the string rule) before publish.
|
|
802
|
+
assert(!/is defined\(verdict\)/.test(gTask), "the generic single-value node must NOT read the fact-named field");
|
|
803
|
+
assert(
|
|
804
|
+
/trim\(string\(if \(is defined\(value\)\) then value else null\)\) != ""/.test(gTask.replaceAll(""", '"')) && /then if \(is defined\(value\)\) then value else null else null/.test(gTask.replaceAll(""", '"')),
|
|
805
|
+
"the generic single-value node keeps the canonical `value` capture, now validated non-blank (fail closed to null)",
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
// The class, not the instance: a GENERIC (no formKey) single-BOOLEAN emit must ALSO coerce its captured
|
|
809
|
+
// `value` text to a real FEEL boolean — the same bug bites the generic textfield, not just an explicit
|
|
810
|
+
// bespoke form (PR #863 thread r4189815989, class sweep). It must NOT read a fact-named field (`verdict`),
|
|
811
|
+
// only the fixed `value` control.
|
|
812
|
+
const genBool = await compileOk({
|
|
813
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "approve?" }, emits: [{ name: "ok", type: "boolean" }] }],
|
|
814
|
+
edges: [],
|
|
815
|
+
});
|
|
816
|
+
const gbEl = elementForNode(genBool.bpmn, "h");
|
|
817
|
+
const gbSub = genBool.bpmn.slice(genBool.bpmn.indexOf(`<bpmn:subProcess id="${gbEl}"`));
|
|
818
|
+
const gbTask = gbSub.slice(gbSub.indexOf("<bpmn:userTask"), gbSub.indexOf("</bpmn:userTask>"));
|
|
819
|
+
assert(gbTask.includes('formId="delivery-human-generic"'), "a generic single-boolean node embeds the generic form");
|
|
820
|
+
const gbOut = gbTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/);
|
|
821
|
+
assert(gbOut, "the generic boolean form maps a value onto humanEmitValue");
|
|
822
|
+
const gbFeel = gbOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
823
|
+
assert(!/is defined\(ok\)/.test(gbFeel), `the generic form must NOT read the fact-named field, got: ${gbFeel}`);
|
|
824
|
+
assert(
|
|
825
|
+
/matches\(lower case\(trim\(string\(.*\)\)\), "\^\(true\|false\)\$"\)/.test(gbFeel) && /= "true"/.test(gbFeel),
|
|
826
|
+
`the generic single-boolean value is coerced/validated to a real FEEL boolean, got: ${gbFeel}`,
|
|
827
|
+
);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("#863 human single-emit: a non-boolean/number fact (version/url/pr/string) is VALIDATED — not just coerced — before publish (review 5430718031 'Previously missed')", async () => {
|
|
831
|
+
// Regression guard (PR #863 review 5430718031, "Validate human-task fact values before publishing"):
|
|
832
|
+
// the human-task path called `coerceFactValueFeel` WITHOUT the type-aware validity gate the escalation
|
|
833
|
+
// resume uses (`resumeValueCondition`). That helper only validates boolean/number; its default arm
|
|
834
|
+
// passes `string`/`version`/`url`/`pr` through unchanged, so a generic/custom form could publish a
|
|
835
|
+
// malformed `version`/`url`/`pr` (or a blank `string`) directly downstream despite the declared type.
|
|
836
|
+
// The fix wraps the binding in `resumeValueCondition` — fail closed to null on an invalid entry, the
|
|
837
|
+
// SAME contract the escalation resume enforces. These compiler-level regex asserts are the
|
|
838
|
+
// red-before/green-after gate (they go red the moment the validity wrap is dropped).
|
|
839
|
+
const version = await compileOk({
|
|
840
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "which version?" }, emits: [{ name: "ver", type: "version" }] }],
|
|
841
|
+
edges: [],
|
|
842
|
+
});
|
|
843
|
+
const vEl = elementForNode(version.bpmn, "h");
|
|
844
|
+
const vSub = version.bpmn.slice(version.bpmn.indexOf(`<bpmn:subProcess id="${vEl}"`));
|
|
845
|
+
const vTask = vSub.slice(vSub.indexOf("<bpmn:userTask"), vSub.indexOf("</bpmn:userTask>"));
|
|
846
|
+
const vOut = vTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/);
|
|
847
|
+
assert(vOut, "a single-version human node maps a value onto humanEmitValue");
|
|
848
|
+
const vFeel = vOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
849
|
+
// The version grammar (`^v?\d[\w.+-]*$`, matching resumeValueCondition) must gate the bind, fail-closed.
|
|
850
|
+
assert(
|
|
851
|
+
/matches\(trim\(string\(.*\)\), "\^v\?\\\\d\[\\\\w\.\+-\]\*\$"\)/.test(vFeel) && /else null/.test(vFeel),
|
|
852
|
+
`a single-version human emit is VALIDATED against the version grammar (fail closed to null), got: ${vFeel}`,
|
|
853
|
+
);
|
|
854
|
+
|
|
855
|
+
// The class, not the instance: a `url` emit must carry the scheme guard, and a `pr` emit the
|
|
856
|
+
// owner/repo#N-or-URL grammar — every text type is validated, not only the cited `version`.
|
|
857
|
+
const url = await compileOk({
|
|
858
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "which url?" }, emits: [{ name: "link", type: "url" }] }],
|
|
859
|
+
edges: [],
|
|
860
|
+
});
|
|
861
|
+
const uEl = elementForNode(url.bpmn, "h");
|
|
862
|
+
const uSub = url.bpmn.slice(url.bpmn.indexOf(`<bpmn:subProcess id="${uEl}"`));
|
|
863
|
+
const uTask = uSub.slice(uSub.indexOf("<bpmn:userTask"), uSub.indexOf("</bpmn:userTask>"));
|
|
864
|
+
const uFeel = (uTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/)?.[2] ?? "").replaceAll(""", '"').replaceAll("'", "'");
|
|
865
|
+
assert(
|
|
866
|
+
/matches\(trim\(string\(.*\)\), "\^\[A-Za-z\]\[A-Za-z0-9\+\.-\]\*:\/\/"\)/.test(uFeel) && /else null/.test(uFeel),
|
|
867
|
+
`a single-url human emit is VALIDATED for a scheme (fail closed to null), got: ${uFeel}`,
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
const pr = await compileOk({
|
|
871
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "which pr?" }, emits: [{ name: "change", type: "pr" }] }],
|
|
872
|
+
edges: [],
|
|
873
|
+
});
|
|
874
|
+
const pEl = elementForNode(pr.bpmn, "h");
|
|
875
|
+
const pSub = pr.bpmn.slice(pr.bpmn.indexOf(`<bpmn:subProcess id="${pEl}"`));
|
|
876
|
+
const pTask = pSub.slice(pSub.indexOf("<bpmn:userTask"), pSub.indexOf("</bpmn:userTask>"));
|
|
877
|
+
const pFeel = (pTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/)?.[2] ?? "").replaceAll(""", '"').replaceAll("'", "'");
|
|
878
|
+
assert(
|
|
879
|
+
/github\\\\\.com/.test(pFeel) && /else null/.test(pFeel),
|
|
880
|
+
`a single-pr human emit is VALIDATED against the owner/repo#N-or-URL grammar (fail closed to null), got: ${pFeel}`,
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
// A `string` emit must be non-blank (the resume gate's only string rule) — fail closed on whitespace.
|
|
884
|
+
const str = await compileOk({
|
|
885
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "say" }, emits: [{ name: "note", type: "string" }] }],
|
|
886
|
+
edges: [],
|
|
887
|
+
});
|
|
888
|
+
const sEl = elementForNode(str.bpmn, "h");
|
|
889
|
+
const sSub = str.bpmn.slice(str.bpmn.indexOf(`<bpmn:subProcess id="${sEl}"`));
|
|
890
|
+
const sTask = sSub.slice(sSub.indexOf("<bpmn:userTask"), sSub.indexOf("</bpmn:userTask>"));
|
|
891
|
+
const sFeel = (sTask.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/)?.[2] ?? "").replaceAll(""", '"').replaceAll("'", "'");
|
|
892
|
+
assert(
|
|
893
|
+
/trim\(string\(.*\)\) != ""/.test(sFeel) && /else null/.test(sFeel),
|
|
894
|
+
`a single-string human emit is VALIDATED non-blank (fail closed to null), got: ${sFeel}`,
|
|
895
|
+
);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("#863 human single-artifact: the artifact handle is VALIDATED against the pkg@version grammar before publish (review 5430718031 'Previously missed')", async () => {
|
|
899
|
+
// Regression guard (PR #863 review 5430718031, "Validate human artifact handles before publishing"): a
|
|
900
|
+
// single-artifact human node published `resolvedArtifact` VERBATIM — the form's `required` rule checks
|
|
901
|
+
// only presence, so `not-an-artifact` was accepted and propagated downstream despite the canonical
|
|
902
|
+
// `pkg@version` contract. The fix wraps the artifact source in the SAME `resumeValueCondition` artifact
|
|
903
|
+
// grammar (`^@?[^@\s]+@v?\d[\w.+-]*$`, scoped-package aware) the escalation resume enforces, fail closed.
|
|
904
|
+
const r = await compileOk({
|
|
905
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "publish it" }, emits: [{ name: "art", type: "artifact" }] }],
|
|
906
|
+
edges: [],
|
|
907
|
+
});
|
|
908
|
+
const el = elementForNode(r.bpmn, "h");
|
|
909
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
910
|
+
const task = sub.slice(sub.indexOf("<bpmn:userTask"), sub.indexOf("</bpmn:userTask>"));
|
|
911
|
+
const aOut = task.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitArtifact"/);
|
|
912
|
+
assert(aOut, "a single-artifact human node maps a value onto humanEmitArtifact");
|
|
913
|
+
const aFeel = aOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
914
|
+
assert(
|
|
915
|
+
/matches\(trim\(string\(if \(is defined\(resolvedArtifact\)\) then resolvedArtifact else null\)\), "\^@\?\[\^@\\\\s\]\+@v\?\\\\d\[\\\\w\.\+-\]\*\$"\)/.test(aFeel) && /else null/.test(aFeel),
|
|
916
|
+
`a single-artifact human emit is VALIDATED against the pkg@version grammar (fail closed to null), got: ${aFeel}`,
|
|
917
|
+
);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("#863 human required emit: an invalid typed completion is GATED back for re-entry, never published null downstream (thread r4198662345)", async () => {
|
|
921
|
+
// Regression guard (PR #863 Copilot High, thread r4198662345): a human node whose single emit is a
|
|
922
|
+
// downstream-REQUIRED fact coerced invalid typed input to null and then flowed DIRECTLY to the
|
|
923
|
+
// subProcess end — the unconditional edge activated the consumer with a null required fact, and a
|
|
924
|
+
// human node has no worker contract gate or Retry path to stop it. The fix grows a FAIL-CLOSED
|
|
925
|
+
// completion gate: the task's OWN output mapping computes a validity flag from the RAW captured field
|
|
926
|
+
// (the SAME per-type `resumeValueCondition` grammar the escalation resume enforces, presence included),
|
|
927
|
+
// and a post-task exclusive gateway routes a VALID completion to the node end while an INVALID one
|
|
928
|
+
// loops back to the human task for re-entry.
|
|
929
|
+
const r = await compileOk({
|
|
930
|
+
nodes: [
|
|
931
|
+
{ id: "h", kind: "human", human: { prompt: "enter a number" }, emits: [{ name: "count", type: "number" }] },
|
|
932
|
+
{ id: "c", kind: "agent", agent: { jobType: "senior:consumer" } },
|
|
933
|
+
],
|
|
934
|
+
edges: [{ from: "h.count", to: "c" }],
|
|
935
|
+
});
|
|
936
|
+
const el = elementForNode(r.bpmn, "h");
|
|
937
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
938
|
+
const task = sub.slice(sub.indexOf("<bpmn:userTask"), sub.indexOf("</bpmn:userTask>"));
|
|
939
|
+
const flagVar = resumeValidVar(`delivery-human-task__${el}`);
|
|
940
|
+
// (1) The task publishes the completion-validity flag, computed from the SAME `selectExpr` SELECTION the
|
|
941
|
+
// emit uses (for a generic form that is the bare `value`, wrapped null-safe) via the number grammar
|
|
942
|
+
// (flat presence `string(sel) != null` + `^-?\d+(\.\d+)?$`), so a blank/non-numeric entry is invalid.
|
|
943
|
+
const flagOut = task.match(new RegExp(`<zeebe:output source=(["'])(.*?)\\1 target="${flagVar}"`));
|
|
944
|
+
assert(flagOut, `the human task publishes the completion-validity flag ${flagVar}`);
|
|
945
|
+
const flagFeel = flagOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
946
|
+
const genericSel = "if (is defined(value)) then value else null";
|
|
947
|
+
// The generic form's emit reads bare `value`, so the flag validates that same selection — and uses the
|
|
948
|
+
// FLAT `string(sel) != null` presence guard (never a bare `is defined(<compound>)`, which the pinned
|
|
949
|
+
// engine mis-evaluates on a compound selection).
|
|
950
|
+
assert(
|
|
951
|
+
flagFeel.includes(`string(${genericSel}) != null`) &&
|
|
952
|
+
flagFeel.includes(`matches(lower case(trim(string(${genericSel}))),`) &&
|
|
953
|
+
/then true else false/.test(flagFeel),
|
|
954
|
+
`the validity flag validates the emit's selection via the number grammar with a flat presence guard (fail closed), got: ${flagFeel}`,
|
|
955
|
+
);
|
|
956
|
+
// (2) The task no longer flows straight to the node end — it routes into a completion gateway…
|
|
957
|
+
assert(sub.includes(`<bpmn:sequenceFlow id="${el}_i1" sourceRef="delivery-human-task__${el}" targetRef="${el}_cg" />`), "the human task routes into the completion gate, not straight to the node end");
|
|
958
|
+
assert(sub.includes(`<bpmn:exclusiveGateway id="${el}_cg"`), "a required-emit human node grows a completion gateway");
|
|
959
|
+
// (3) …whose VALID branch reaches the node end and whose DEFAULT (invalid) branch loops back to the task.
|
|
960
|
+
assert(
|
|
961
|
+
sub.includes(`<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>`),
|
|
962
|
+
"the valid branch reaches the node end on `<flag> = true`",
|
|
963
|
+
);
|
|
964
|
+
assert(
|
|
965
|
+
sub.includes(`<bpmn:sequenceFlow id="${el}_cbad" name="invalid — re-enter" sourceRef="${el}_cg" targetRef="delivery-human-task__${el}" />`),
|
|
966
|
+
"the invalid (default) branch loops back to the human task for re-entry",
|
|
967
|
+
);
|
|
968
|
+
assert(sub.includes(`<bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_cok</bpmn:incoming></bpmn:endEvent>`), "the node end is reached only via the valid branch");
|
|
969
|
+
|
|
970
|
+
// The class, not the instance: the SAME gate guards a single-emit human node of EVERY type — a
|
|
971
|
+
// routing-only/unconsumed emit (no downstream data binding) must NOT grow it (its null just takes a
|
|
972
|
+
// guarded split's default, like the escalation resume's no-required-target case).
|
|
973
|
+
const unconsumed = await compileOk({ nodes: [{ id: "h", kind: "human", human: { prompt: "x" }, emits: [{ name: "count", type: "number" }] }], edges: [] });
|
|
974
|
+
assert(!unconsumed.bpmn.includes("_cg"), "an unconsumed (routing-only) single-emit human node grows NO completion gate");
|
|
975
|
+
const noEmit = await compileOk({ nodes: [{ id: "h", kind: "human", human: { prompt: "x" } }], edges: [] });
|
|
976
|
+
assert(!noEmit.bpmn.includes("_cg"), "a no-emit human node grows NO completion gate");
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test("#863 human required emit on an EXPLICIT bespoke form: the completion-validity flag validates the FACT-NAMED control, not a bare always-absent field (adversarial High)", async () => {
|
|
980
|
+
// Regression guard (PR #863 adversarial round-27, `deliveryGraphCompiler.ts` validity flag): for an
|
|
981
|
+
// EXPLICIT `human.formKey` form the emit's `selectExpr` reads the FACT-NAMED control (`count`) first, but
|
|
982
|
+
// the completion-validity flag used to validate the BARE `value`/`resolvedArtifact` field. An operator
|
|
983
|
+
// form keyed by the fact name captures into `<fact>`, leaving `value` undefined, so a GENUINELY VALID
|
|
984
|
+
// explicit-form completion evaluated the flag `false` → the gateway's `_cbad` default re-parked the human
|
|
985
|
+
// forever (until SLA escalation): a correct human answer could never complete the node. The flag must
|
|
986
|
+
// validate the SAME selection the emit publishes.
|
|
987
|
+
const r = await compileOk({
|
|
988
|
+
nodes: [
|
|
989
|
+
{ id: "h", kind: "human", human: { prompt: "enter a number", formKey: "bespoke-count" }, emits: [{ name: "count", type: "number" }] },
|
|
990
|
+
{ id: "c", kind: "agent", agent: { jobType: "senior:consumer" } },
|
|
991
|
+
],
|
|
992
|
+
edges: [{ from: "h.count", to: "c" }],
|
|
993
|
+
});
|
|
994
|
+
const el = elementForNode(r.bpmn, "h");
|
|
995
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
996
|
+
const task = sub.slice(sub.indexOf("<bpmn:userTask"), sub.indexOf("</bpmn:userTask>"));
|
|
997
|
+
const flagVar = resumeValidVar(`delivery-human-task__${el}`);
|
|
998
|
+
// The explicit-form selection prefers the fact-named control `count`, falling back to `value`.
|
|
999
|
+
const explicitSel = "if (is defined(count) and count != null and string(count) != null) then count else if (is defined(value)) then value else null";
|
|
1000
|
+
const flagOut = task.match(new RegExp(`<zeebe:output source=(["'])(.*?)\\1 target="${flagVar}"`));
|
|
1001
|
+
assert(flagOut, `the explicit-form human task publishes the completion-validity flag ${flagVar}`);
|
|
1002
|
+
const flagFeel = flagOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
1003
|
+
// The flag validates the EMIT's selection (the fact-named `count` control), with the same flat presence
|
|
1004
|
+
// guard + number grammar — NOT the bare `value` the pre-fix flag read (which an explicit form leaves
|
|
1005
|
+
// undefined, rejecting a valid completion forever).
|
|
1006
|
+
assert(
|
|
1007
|
+
flagFeel.includes(`string(${explicitSel}) != null`) &&
|
|
1008
|
+
flagFeel.includes(`matches(lower case(trim(string(${explicitSel}))),`) &&
|
|
1009
|
+
/then true else false/.test(flagFeel),
|
|
1010
|
+
`the explicit-form validity flag must validate the fact-named control selection, got: ${flagFeel}`,
|
|
1011
|
+
);
|
|
1012
|
+
// Cross-check: the emit source and the validity flag read the SAME selection — they cannot diverge.
|
|
1013
|
+
const valueOut = task.match(/<zeebe:output source=(["'])(.*?)\1 target="humanEmitValue"/);
|
|
1014
|
+
assert(valueOut, "the explicit-form human task maps a value onto humanEmitValue");
|
|
1015
|
+
const valueFeel = valueOut[2].replaceAll(""", '"').replaceAll("'", "'");
|
|
1016
|
+
assert(valueFeel.includes(explicitSel), `the emit source reads the fact-named control selection, got: ${valueFeel}`);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
test("#863 review r4201222762: a human node's completion-gate flag dodges a declared emit named EXACTLY the generated flag — never shadowed by its node-local null-seed", async () => {
|
|
1020
|
+
// The completion-validity flag (`resumeValidVar(delivery-human-task__<el>)`) is itself a legal
|
|
1021
|
+
// fact-name string, and a human node null-seeds each declared emit's fact NAME node-local
|
|
1022
|
+
// (`ioMappingLines`) so a parallel sibling's value can't leak in. If a required single emit is named
|
|
1023
|
+
// EXACTLY the generated flag, the flag lands in that node-local null-seeded var — and the
|
|
1024
|
+
// immediately-following completion gateway reads the stale `null` (the multi-instance `=null`-shadow
|
|
1025
|
+
// gotcha), routing the default `_cbad` branch so even a VALID answer re-parks the human forever. The
|
|
1026
|
+
// flag must grow collision-free (deterministic `_` suffix), exactly as `escalationResumeFlagVar` does
|
|
1027
|
+
// for service-node emits. Probe-compile to learn the (topology-stable) element id, then name the emit
|
|
1028
|
+
// the exact flag var.
|
|
1029
|
+
const probe = await compileOk({
|
|
1030
|
+
nodes: [
|
|
1031
|
+
{ id: "h", kind: "human", human: { prompt: "answer" }, emits: [{ name: "probeFact", type: "string" }] },
|
|
1032
|
+
{ id: "c", kind: "agent", agent: { jobType: "senior:consumer" } },
|
|
1033
|
+
],
|
|
1034
|
+
edges: [{ from: "h.probeFact", to: "c" }],
|
|
1035
|
+
});
|
|
1036
|
+
const collisionName = resumeValidVar(`delivery-human-task__${elementForNode(probe.bpmn, "h")}`);
|
|
1037
|
+
assert(collisionName.includes("__flag__"), "sanity: the human flag var carries the internal infix");
|
|
1038
|
+
const r = await compileOk({
|
|
1039
|
+
nodes: [
|
|
1040
|
+
{ id: "h", kind: "human", human: { prompt: "answer" }, emits: [{ name: collisionName, type: "string" }] },
|
|
1041
|
+
{ id: "c", kind: "agent", agent: { jobType: "senior:consumer" } },
|
|
1042
|
+
],
|
|
1043
|
+
edges: [{ from: `h.${collisionName}`, to: "c" }],
|
|
1044
|
+
});
|
|
1045
|
+
const el = elementForNode(r.bpmn, "h");
|
|
1046
|
+
assertEquals(el, elementForNode(probe.bpmn, "h"), "sanity: renaming the emit leaves topology (and the element id) unchanged");
|
|
1047
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
1048
|
+
const task = sub.slice(sub.indexOf("<bpmn:userTask"), sub.indexOf("</bpmn:userTask>"));
|
|
1049
|
+
// The declared emit's fact name is STILL null-seeded node-local (its anti-leak isolation is untouched);
|
|
1050
|
+
// the seed lives in the subProcess ioMapping, outside the userTask.
|
|
1051
|
+
assert(r.bpmn.includes(`<zeebe:input source="=null" target="${collisionName}" />`), "the declared emit's fact name stays null-seeded node-local");
|
|
1052
|
+
// The flag binds under a DISTINCT var — the collision-free `_`-suffixed name, never the emit's var.
|
|
1053
|
+
const suffixedFlag = `${collisionName}_`;
|
|
1054
|
+
assert(task.includes(`target="${suffixedFlag}"`), `the completion-validity flag binds under the distinct suffixed var, got: ${task}`);
|
|
1055
|
+
// … and the gateway routes on that SAME suffixed flag var (never the colliding emit var), so a valid
|
|
1056
|
+
// completion can actually reach the node end instead of re-parking forever.
|
|
1057
|
+
assert(
|
|
1058
|
+
sub.includes(`<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${suffixedFlag} = true</bpmn:conditionExpression>`),
|
|
1059
|
+
`the valid branch routes on the suffixed flag var, got: ${sub}`,
|
|
1060
|
+
);
|
|
1061
|
+
assert(!sub.includes(`=${collisionName} = true`), "the valid branch must NOT route on the colliding emit var (which is node-local null-seeded)");
|
|
348
1062
|
});
|
|
349
1063
|
|
|
350
1064
|
// An agent that owes a required `pr` emit to a downstream consumer — its timeout `__esc` (and a
|
|
@@ -426,7 +1140,7 @@ test("#876 review: a resumed WAIT-gate escalation validates against its emit typ
|
|
|
426
1140
|
// resume-valid flag for the gateway.
|
|
427
1141
|
assert(/<zeebe:output [\s\S]*?target="detail" \/>/.test(esc), "the wait escalation binds the version emit's source var (detail) on its output");
|
|
428
1142
|
assert(/<zeebe:output [\s\S]*?target="[^"]*__resumeValid" \/>/.test(esc), "the wait escalation flags its resume-valid flag");
|
|
429
|
-
const gateStart = r.bpmn.indexOf(`<bpmn:exclusiveGateway id="delivery-human-task__${n2El}
|
|
1143
|
+
const gateStart = r.bpmn.indexOf(`<bpmn:exclusiveGateway id="delivery-human-task__${n2El}__esc__waitVg"`);
|
|
430
1144
|
assert(gateStart !== -1, "the wait timeout escalation grows a resume-validation gateway");
|
|
431
1145
|
});
|
|
432
1146
|
|
|
@@ -464,6 +1178,63 @@ test("#876 review: a multi-required-emit escalation FAILS CLOSED — it binds no
|
|
|
464
1178
|
}
|
|
465
1179
|
});
|
|
466
1180
|
|
|
1181
|
+
// A MULTI-emit WAIT node: a wait gate owing >1 fact. Unlike an agent/connector, a wait escalation has
|
|
1182
|
+
// NO Retry gate (its resolution is "supply the awaited value and continue"), and the single escalation-
|
|
1183
|
+
// form `value` field cannot supply two distinct facts — so a resume-validation gate here would re-park
|
|
1184
|
+
// EVERY Continue with no escape, wedging the timed-out token forever (issue #863 review r4198123619,
|
|
1185
|
+
// "Prevent unresolvable loops for multi-emit wait nodes"). The compiler must therefore grow NO gate and
|
|
1186
|
+
// let the escalation continue straight to the node end.
|
|
1187
|
+
const MULTI_EMIT_WAIT_GRAPH = {
|
|
1188
|
+
name: "multi-emit wait unresolvable loop",
|
|
1189
|
+
nodes: [
|
|
1190
|
+
{ id: "gv", kind: "agent", agent: { jobType: "senior:feature", prompt: "ship the rollup" } },
|
|
1191
|
+
{
|
|
1192
|
+
id: "w",
|
|
1193
|
+
kind: "wait",
|
|
1194
|
+
wait: {
|
|
1195
|
+
kind: "capability",
|
|
1196
|
+
target: "github-releases:nanobpm/nano-ide",
|
|
1197
|
+
match: { package: "@nanobpm/urban", capabilityRef: "#500" },
|
|
1198
|
+
onTimeout: "escalate",
|
|
1199
|
+
},
|
|
1200
|
+
emits: [
|
|
1201
|
+
{ name: "publishedVersion", type: "version" },
|
|
1202
|
+
{ name: "artifactRef", type: "artifact" },
|
|
1203
|
+
],
|
|
1204
|
+
},
|
|
1205
|
+
{ id: "sink", kind: "connector", connector: { target: "npm:install", dedupeKey: "c1" } },
|
|
1206
|
+
],
|
|
1207
|
+
edges: [
|
|
1208
|
+
{ from: "gv", to: "w" },
|
|
1209
|
+
{ from: "w.publishedVersion", to: "sink" },
|
|
1210
|
+
{ from: "w.artifactRef", to: "sink" },
|
|
1211
|
+
],
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
test("#863 review r4198123619: a MULTI-emit wait escalation grows NO resume-validation gate (would be an unresolvable loop: no retry, Continue always invalid) and continues straight to the node end", async () => {
|
|
1215
|
+
const r = await compileOk(MULTI_EMIT_WAIT_GRAPH);
|
|
1216
|
+
const wEl = elementForNode(r.bpmn, "w");
|
|
1217
|
+
const esc = `delivery-human-task__${wEl}__esc__wait`;
|
|
1218
|
+
// The escalation task itself still exists (operator is stopped so a timed-out multi-emit gate is
|
|
1219
|
+
// legible), but it is NOT value-resumable.
|
|
1220
|
+
assert(r.bpmn.includes(`<bpmn:userTask id="${esc}"`), "the multi-emit wait still parks on a human escalation task");
|
|
1221
|
+
// NO resume-validation gateway: it would route every Continue to its invalid (default) branch and
|
|
1222
|
+
// loop back onto the escalation task, which — with no Retry escape — can never advance.
|
|
1223
|
+
assert(!r.bpmn.includes(`<bpmn:exclusiveGateway id="${esc}Vg"`), "a multi-emit wait escalation grows NO resume-validation gateway");
|
|
1224
|
+
assert(!r.bpmn.includes(`${esc}Vbad`), "a multi-emit wait escalation has NO invalid-resume re-park flow (no unresolvable loop)");
|
|
1225
|
+
assert(!r.bpmn.includes(`${esc}Vok`), "a multi-emit wait escalation has NO valid-resume branch");
|
|
1226
|
+
// Instead the escalation's return flow `_i5` flows straight to the node end — an acknowledged,
|
|
1227
|
+
// value-less continue (the same path a no-emit wait escalation takes).
|
|
1228
|
+
assert(
|
|
1229
|
+
r.bpmn.includes(`<bpmn:sequenceFlow id="${wEl}_i5" sourceRef="${esc}" targetRef="${wEl}_end" />`),
|
|
1230
|
+
"the multi-emit wait escalation continues straight to the node end",
|
|
1231
|
+
);
|
|
1232
|
+
// And it binds NO fact value (the single value field can't supply two distinct facts).
|
|
1233
|
+
const escBlock = escBlockForNodeSuffix(r.bpmn, "w", "esc__wait");
|
|
1234
|
+
assert(escBlock.includes('="none"') && escBlock.includes('target="emitMode"'), "a multi-emit wait escalation hides its value field (emitMode none)");
|
|
1235
|
+
assert(!/then value else null/.test(escBlock), "a multi-emit wait escalation writes NO value→emit mapping");
|
|
1236
|
+
});
|
|
1237
|
+
|
|
467
1238
|
test("#876 round-4 review: the resume-valid flag is distinct from any declared emit's fact-source var", async () => {
|
|
468
1239
|
// The flag is derived from the escalation element id, which is itself a legal fact-name string — so
|
|
469
1240
|
// a node could declare an emit NAMED `delivery_human_task__<el>__esc__resumeValid`, and for an
|
|
@@ -764,22 +1535,43 @@ test("humanNodes: extracts prompt/formKey/emits; a click-done node emits nothing
|
|
|
764
1535
|
assertEquals(ack?.prompt, undefined);
|
|
765
1536
|
});
|
|
766
1537
|
|
|
767
|
-
test("#
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
|
|
773
|
-
|
|
1538
|
+
test("#863 human.formKey: a credential-bearing formKey is REJECTED before compilation (HIGH finding), while a plain opaque form id compiles verbatim", async () => {
|
|
1539
|
+
// PR #863 review (HIGH): an explicit `human.formKey` is baked into the executable
|
|
1540
|
+
// `<zeebe:formDefinition formId=…>`, so a credential-bearing URL formKey (`//user:pass@…`) must be
|
|
1541
|
+
// REJECTED at the semantic boundary — not merely redacted in the preview projection — so it can never
|
|
1542
|
+
// reach the compiled/deployed BPMN at all. (The preview redactor on `humanNodes[].formKey` remains as
|
|
1543
|
+
// residual defence-in-depth for an opaque query-bearing form id the URL/credential gate does not catch.)
|
|
1544
|
+
const errors = await compileFail({
|
|
1545
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "https://forms.example.com/approve?token=abc" } }],
|
|
774
1546
|
edges: [],
|
|
775
1547
|
});
|
|
776
|
-
const
|
|
777
|
-
assert(
|
|
778
|
-
|
|
1548
|
+
const e = errors.find((err) => err.path === "nodes[0].human.formKey");
|
|
1549
|
+
assert(e !== undefined, `expected a formKey rejection, got ${JSON.stringify(errors)}`);
|
|
1550
|
+
assert(!e.message.includes("token=abc"), `the formKey rejection message must redact the query token, got: ${e.message}`);
|
|
1551
|
+
// An ordinary opaque form id is accepted and preserved verbatim in the preview projection.
|
|
779
1552
|
const plain = await compileOk({ nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: "deploy-approval" } }], edges: [] });
|
|
780
1553
|
assertEquals(plain.humanNodes.find((n) => n.nodeId === "h")?.formKey, "deploy-approval");
|
|
781
1554
|
});
|
|
782
1555
|
|
|
1556
|
+
test("#863 human.formKey: a quote-bearing formKey can never break out of the `<zeebe:formDefinition formId=…>` attribute (rendered via `attr()`)", async () => {
|
|
1557
|
+
// A formKey is XML-attribute-safe but need not be injection-safe on its own: the compiler renders it
|
|
1558
|
+
// through `attr()`, which switches the attribute delimiter to a single quote when the value contains a
|
|
1559
|
+
// `"`, so a crafted `"/><bpmn:…` payload stays INSIDE the attribute instead of closing it. (A
|
|
1560
|
+
// `"`-bearing formKey is not URL/credential/control-char shaped, so the validator admits it — defence
|
|
1561
|
+
// in depth: even an admitted hostile key cannot inject.)
|
|
1562
|
+
const r = await compileOk({
|
|
1563
|
+
nodes: [{ id: "h", kind: "human", human: { prompt: "approve", formKey: 'a"/><bpmn:userTask id="pwn' } }],
|
|
1564
|
+
edges: [],
|
|
1565
|
+
});
|
|
1566
|
+
// No raw `<bpmn:userTask id="pwn` element was injected — its `<`/`>` are entity-escaped INSIDE a
|
|
1567
|
+
// single-quote-delimited attribute, so the payload stays contained as attribute text.
|
|
1568
|
+
assert(!r.bpmn.includes('<bpmn:userTask id="pwn'), "a quote-bearing formKey must not inject a sibling element");
|
|
1569
|
+
assert(
|
|
1570
|
+
r.bpmn.includes(`formId='a"/><bpmn:userTask id="pwn'`),
|
|
1571
|
+
`formKey must be single-quote delimited with < > escaped, BPMN: ${r.bpmn.slice(r.bpmn.indexOf("formDefinition") - 10, r.bpmn.indexOf("formDefinition") + 90)}`,
|
|
1572
|
+
);
|
|
1573
|
+
});
|
|
1574
|
+
|
|
783
1575
|
test("#778 humanNodes: a credential-bearing emit `description` is redacted in the preview projection (raw only reaches runtime)", async () => {
|
|
784
1576
|
// `humanNodes[].emits` is persisted into the staged proposal `preview` and rendered on the Delivery
|
|
785
1577
|
// Graphs page, so a `//user:pass@…` URL in a free-form fact `description` must be stripped here with the
|
|
@@ -1176,9 +1968,9 @@ test("a wait node's onTimeout: continue proceeds past the gate with NO escalatio
|
|
|
1176
1968
|
"continue routes the not-ready boundary branch to the node end",
|
|
1177
1969
|
);
|
|
1178
1970
|
// escalate: `hard` keeps its escalation twin and routes not-ready to it.
|
|
1179
|
-
assert(r.bpmn.includes(`delivery-human-task__${hardEl}
|
|
1971
|
+
assert(r.bpmn.includes(`delivery-human-task__${hardEl}__esc__wait`), "escalate keeps the escalation user task");
|
|
1180
1972
|
assert(
|
|
1181
|
-
r.bpmn.includes(`<bpmn:sequenceFlow id="${hardEl}_i4" name="not ready" sourceRef="${hardEl}_lastGw" targetRef="delivery-human-task__${hardEl}
|
|
1973
|
+
r.bpmn.includes(`<bpmn:sequenceFlow id="${hardEl}_i4" name="not ready" sourceRef="${hardEl}_lastGw" targetRef="delivery-human-task__${hardEl}__esc__wait" />`),
|
|
1182
1974
|
"escalate routes the not-ready boundary branch to the escalation task",
|
|
1183
1975
|
);
|
|
1184
1976
|
});
|
|
@@ -1229,6 +2021,14 @@ const PRODUCER_GATE = {
|
|
|
1229
2021
|
edges: [{ from: "open.pr", to: "land" }],
|
|
1230
2022
|
};
|
|
1231
2023
|
|
|
2024
|
+
/** The producer-contract expression an agent task publishes as `<el>_contractMet` (evaluated in the
|
|
2025
|
+
* node's scope; the gate's success flow reads only that node-unique boolean). */
|
|
2026
|
+
function contractMetExpr(bpmn: string, el: string): string {
|
|
2027
|
+
const m = bpmn.match(new RegExp(`<zeebe:output source='([^']*)' target="${el}_contractMet" />`));
|
|
2028
|
+
assert(m, `the ${el} task publishes ${el}_contractMet`);
|
|
2029
|
+
return m![1].replaceAll(""", '"');
|
|
2030
|
+
}
|
|
2031
|
+
|
|
1232
2032
|
test("#731 producer status gate: an agent node inserts a post-completion contract gate that escalates a non-terminal status AT the producer", async () => {
|
|
1233
2033
|
const r = await compileOk(PRODUCER_GATE);
|
|
1234
2034
|
const el = elementForNode(r.bpmn, "open");
|
|
@@ -1248,8 +2048,10 @@ test("#731 producer status gate: an agent node inserts a post-completion contrac
|
|
|
1248
2048
|
// `in_progress`/`blocked`/`failed` self-report falls through to the default → escalation.
|
|
1249
2049
|
const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
|
|
1250
2050
|
assert(g0, "the contract-met success flow exists");
|
|
1251
|
-
assert(g0![1].includes(
|
|
1252
|
-
|
|
2051
|
+
assert(g0![1].includes(`=${el}_contractMet = true`), "the success flow reads the node-unique contract decision");
|
|
2052
|
+
const cond = contractMetExpr(r.bpmn, el);
|
|
2053
|
+
assert(cond.includes('list contains(["done", "opened", "skipped"], status)'), "the success flow gates on the terminal-success status allowlist");
|
|
2054
|
+
assert(cond.includes("not(is defined(status)) or status = null"), "an absent/null status is not itself the failure mode — it still proceeds");
|
|
1253
2055
|
// The contract escalation's read-only context names the node and its reported status (#731).
|
|
1254
2056
|
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
1255
2057
|
assert(esc.includes("did not satisfy its producer contract"), "the contract escalation explains WHY it parked");
|
|
@@ -1263,7 +2065,7 @@ test("#731 required-emit gate: a producer's declared emit consumed as a required
|
|
|
1263
2065
|
assert(g0, "the contract-met success flow exists");
|
|
1264
2066
|
// `pr` is threaded to `land`'s connector payload as a required data dependency — so the gate proceeds
|
|
1265
2067
|
// only when it is actually populated non-null (a null `pr`, as in instance 10746, escalates here).
|
|
1266
|
-
assert(
|
|
2068
|
+
assert(contractMetExpr(r.bpmn, el).includes("(is defined(pr) and pr != null)"), "a required-emit non-null clause gates the success flow on the populated fact");
|
|
1267
2069
|
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
1268
2070
|
assert(esc.includes("Required emit 'pr'"), "the escalation NAMES the required fact that was not emitted");
|
|
1269
2071
|
// Resumable (#514 Defect-B mirror): a human/agent supplies the missing fact, VALIDATED on the task's
|
|
@@ -1295,8 +2097,9 @@ test("#731 routing-only emits stay optional: a fact referenced ONLY by an edge `
|
|
|
1295
2097
|
const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
|
|
1296
2098
|
assert(g0, "the contract-met success flow exists");
|
|
1297
2099
|
// The status gate is still present, but there is NO `result` non-null clause — routing stays optional.
|
|
1298
|
-
|
|
1299
|
-
assert(
|
|
2100
|
+
const cond = contractMetExpr(r.bpmn, el);
|
|
2101
|
+
assert(cond.includes("list contains"), "the status gate is still present for the agent node");
|
|
2102
|
+
assert(!cond.includes("result"), `a routing-only emit is NOT gated as a required data dependency, got: ${cond}`);
|
|
1300
2103
|
// The contract escalation for a status-only gate is inert (no emit resume field).
|
|
1301
2104
|
const esc = escBlockForNodeSuffix(r.bpmn, "classify", "contract");
|
|
1302
2105
|
assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "a status-only contract escalation keeps its emit field hidden");
|
|
@@ -1913,7 +2716,7 @@ test("#778 the wait + human inner tasks carry the descriptive display name (not
|
|
|
1913
2716
|
name: "inner names",
|
|
1914
2717
|
nodes: [
|
|
1915
2718
|
{ id: "gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } } },
|
|
1916
|
-
{ id: "otp", kind: "human", human: { prompt: "Run the manual OTP publish"
|
|
2719
|
+
{ id: "otp", kind: "human", human: { prompt: "Run the manual OTP publish" } },
|
|
1917
2720
|
],
|
|
1918
2721
|
edges: [{ from: "gate", to: "otp" }],
|
|
1919
2722
|
};
|
|
@@ -2680,3 +3483,515 @@ test("#778 redactFreeText: redacts a URL query/fragment orphaned across a smuggl
|
|
|
2680
3483
|
const prose = redactFreeText("visit //example.com then\nis this ok? yes");
|
|
2681
3484
|
assert(prose.includes("is this ok? yes"), `prose after a whitespace break is intact: ${prose}`);
|
|
2682
3485
|
});
|
|
3486
|
+
|
|
3487
|
+
// ── node-scope hardening (instance 171774: three escalations with no actionable information) ────────
|
|
3488
|
+
|
|
3489
|
+
test("node scope: an agent node declares its result vars node-local so parallel siblings never share a root status/pr", async () => {
|
|
3490
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3491
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3492
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3493
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3494
|
+
for (const v of ["status", "summary", "question", "error", "pr", "transcriptUrl", "delta", "decision", "value", "escalationNote"]) {
|
|
3495
|
+
assert(io.includes(`source="=null" target="${v}"`), `'${v}' is declared node-local on the subProcess`);
|
|
3496
|
+
}
|
|
3497
|
+
// The plain `note` is declared node-local too — as a plan.md WORKER RESULT field via
|
|
3498
|
+
// AGENT_RESULT_LOCAL_VARS, NOT as an escalation control (the retry-guidance control is the distinct
|
|
3499
|
+
// `escalationNote`, PR #863 "Previously missed", deliveryGraphCompiler.ts:2156).
|
|
3500
|
+
assert(io.includes(`source="=null" target="note"`), "the worker-result `note` is declared node-local (AGENT_RESULT_LOCAL_VARS)");
|
|
3501
|
+
});
|
|
3502
|
+
|
|
3503
|
+
test("#863 node scope: an agent's documented optional `delta` result is node-local, so parallel senior:feature nodes never overwrite one another's delta at root", async () => {
|
|
3504
|
+
// Regression guard (#863 review r4180629336): `delta` is a documented top-level implementer-result
|
|
3505
|
+
// field (resources/prompts/feature.md). Omitted from AGENT_RESULT_LOCAL_VARS, a delivery node using
|
|
3506
|
+
// `senior:feature` would write `delta` at ROOT scope, where a parallel node overwrites it and a retry
|
|
3507
|
+
// leaves it stale. It must be declared node-local and cleared on retry.
|
|
3508
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3509
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3510
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3511
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3512
|
+
assert(io.includes(`source="=null" target="delta"`), "the agent's `delta` result is declared node-local");
|
|
3513
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3514
|
+
assert(/<zeebe:output source="=null" target="delta" \/>/.test(reset), "the retry reset clears a stale `delta` from the previous attempt");
|
|
3515
|
+
});
|
|
3516
|
+
|
|
3517
|
+
test("#863 node scope: every documented review/audit result field (plan-review/conformance) is node-local and cleared on retry — the canonical result contract cannot drift", async () => {
|
|
3518
|
+
// Regression guard (PR #863 Copilot "Previously missed", deliveryGraphCompiler.ts:214): the node-local
|
|
3519
|
+
// result list omitted documented agent result fields, so a parallel delivery node using those contracts
|
|
3520
|
+
// leaked them to the shared root (and a retry left them stale). `plan-review.md` returns
|
|
3521
|
+
// `approved`/`findings`; `conformance.md` returns `commentUrl`, `hasDeviations`, and the per-slice /
|
|
3522
|
+
// deviation counts. Each must be declared node-local AND cleared on retry.
|
|
3523
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3524
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3525
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3526
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3527
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3528
|
+
for (const v of ["approved", "findings", "commentUrl", "hasDeviations", "slicesMet", "slicesReduced", "slicesNotVerified", "deviationsRaised", "deviationsUnraised"]) {
|
|
3529
|
+
assert(io.includes(`source="=null" target="${v}"`), `the documented result field '${v}' is declared node-local`);
|
|
3530
|
+
assert(new RegExp(`<zeebe:output source="=null" target="${v}" />`).test(reset), `the retry reset clears a stale '${v}'`);
|
|
3531
|
+
}
|
|
3532
|
+
});
|
|
3533
|
+
|
|
3534
|
+
test("#863 node scope: the remaining built-in prompt result contracts (fix-ci/rebase/adversarial-review/scope-classify/plan/trial-merge) are node-local and cleared on retry (r4181321969)", async () => {
|
|
3535
|
+
// Regression guard (PR #863 Copilot High, thread r4181321969): the "canonical" result contract was
|
|
3536
|
+
// incomplete relative to the built-in prompts it covers — `fix-ci.md`/`rebase.md` return `dependsOn`,
|
|
3537
|
+
// `adversarial-review.md` returns `adversarialFindings`/`adversarialSummary`, `scope-classify.md`
|
|
3538
|
+
// returns `scopeBlocked`/`scopeBlockReason`, `plan.md` returns `tasks`, `trial-merge.md` returns
|
|
3539
|
+
// `result`/`conflicts`/`failing`. A delivery node using one of those shapes wrote the field at the
|
|
3540
|
+
// shared ROOT, where a parallel node overwrites it and a retry retains a stale value. Each must be
|
|
3541
|
+
// declared node-local AND cleared on retry.
|
|
3542
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3543
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3544
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3545
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3546
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3547
|
+
for (const v of ["dependsOn", "adversarialFindings", "adversarialSummary", "scopeBlocked", "scopeBlockReason", "tasks", "result", "conflicts", "failing"]) {
|
|
3548
|
+
assert(io.includes(`source="=null" target="${v}"`), `the documented result field '${v}' is declared node-local`);
|
|
3549
|
+
assert(new RegExp(`<zeebe:output source="=null" target="${v}" />`).test(reset), `the retry reset clears a stale '${v}'`);
|
|
3550
|
+
}
|
|
3551
|
+
});
|
|
3552
|
+
|
|
3553
|
+
test("node scope: a connector node localises every declared emit source var so two parallel connectors with the same emit never cross-publish", async () => {
|
|
3554
|
+
// Regression guard (PR #863 Copilot review, thread r4179717614): a `DeliveryNodeConnector` permits
|
|
3555
|
+
// `emits` too, and its emit source is the fact's own name. Without localising it on the connector's
|
|
3556
|
+
// subProcess, two parallel connectors declaring the same field read/write it at ROOT scope, so a
|
|
3557
|
+
// timed-out connector could publish a sibling connector's result through its own `<el>_<fact>` output.
|
|
3558
|
+
const graph = {
|
|
3559
|
+
name: "two-connectors",
|
|
3560
|
+
nodes: [
|
|
3561
|
+
{ id: "notifyA", kind: "connector", connector: { target: "slack:#a", payload: { pr: null } }, emits: [{ name: "ack", type: "string" }] },
|
|
3562
|
+
{ id: "notifyB", kind: "connector", connector: { target: "slack:#b", payload: { pr: null } }, emits: [{ name: "ack", type: "string" }] },
|
|
3563
|
+
],
|
|
3564
|
+
edges: [],
|
|
3565
|
+
};
|
|
3566
|
+
const r = await compileOk(graph);
|
|
3567
|
+
for (const nodeId of ["notifyA", "notifyB"]) {
|
|
3568
|
+
const el = elementForNode(r.bpmn, nodeId);
|
|
3569
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3570
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3571
|
+
assert(io.includes(`source="=null" target="ack"`), `connector '${nodeId}' declares its 'ack' emit source var node-local on the subProcess`);
|
|
3572
|
+
// And it still publishes its own node's value onward under the flat node-unique name.
|
|
3573
|
+
assert(io.includes(`target="${el}_ack"`), `connector '${nodeId}' still publishes its emit as the node-unique ${el}_ack`);
|
|
3574
|
+
}
|
|
3575
|
+
});
|
|
3576
|
+
|
|
3577
|
+
test("node scope: a human node localises its emit scratch + form controls so two parallel human nodes never cross-publish a sibling's answer", async () => {
|
|
3578
|
+
// Regression guard (PR #863 Copilot review, thread r4199949849 "Isolate human subprocess scratch state
|
|
3579
|
+
// to prevent cross-run leakage"): a human user task captures the operator's answer into FIXED,
|
|
3580
|
+
// non-node-unique scratch (`humanEmitValue`/`humanEmitArtifact`) by SELECTING from fixed form controls
|
|
3581
|
+
// (`value`/`resolvedArtifact`/`note`). Left at the shared ROOT, if human A completes while human B times
|
|
3582
|
+
// out WITHOUT producing a value, B's subProcess output reads A's root `humanEmitValue` and publishes it
|
|
3583
|
+
// as B's own `<el>_<fact>` (the complete-one/timeout-the-other leak). Each human subProcess must declare
|
|
3584
|
+
// this scratch + its form controls node-local so a value-less timeout fail-closes to its OWN null.
|
|
3585
|
+
const graph = {
|
|
3586
|
+
name: "two-humans",
|
|
3587
|
+
nodes: [
|
|
3588
|
+
{ id: "approveA", kind: "human", human: { prompt: "approve A" }, emits: [{ name: "verdict", type: "string" }] },
|
|
3589
|
+
{ id: "approveB", kind: "human", human: { prompt: "approve B" }, emits: [{ name: "verdict", type: "string" }] },
|
|
3590
|
+
],
|
|
3591
|
+
edges: [],
|
|
3592
|
+
};
|
|
3593
|
+
const r = await compileOk(graph);
|
|
3594
|
+
for (const nodeId of ["approveA", "approveB"]) {
|
|
3595
|
+
const el = elementForNode(r.bpmn, nodeId);
|
|
3596
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3597
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3598
|
+
for (const v of ["humanEmitValue", "humanEmitArtifact", "humanOutcome", "humanNote", "value", "resolvedArtifact", "note"]) {
|
|
3599
|
+
assert(io.includes(`source="=null" target="${v}"`), `human '${nodeId}' declares '${v}' node-local on the subProcess`);
|
|
3600
|
+
}
|
|
3601
|
+
// And it still publishes its own node's value onward under the flat node-unique name.
|
|
3602
|
+
assert(io.includes(`target="${el}_verdict"`), `human '${nodeId}' still publishes its emit as the node-unique ${el}_verdict`);
|
|
3603
|
+
}
|
|
3604
|
+
});
|
|
3605
|
+
|
|
3606
|
+
test("node scope: a human node with a bespoke single-emit form localises the explicit FACT-NAMED control too", async () => {
|
|
3607
|
+
// Regression guard (PR #863 Copilot review, thread r4199949849): a bespoke single-emit human form
|
|
3608
|
+
// captures the value under the FACT'S OWN NAME (`selectExpr` reads `<factName>` before the canonical
|
|
3609
|
+
// control), so that control must ALSO be node-local — otherwise a blank completion reads a sibling's
|
|
3610
|
+
// root value for that fact name.
|
|
3611
|
+
const graph = {
|
|
3612
|
+
name: "bespoke-human",
|
|
3613
|
+
nodes: [
|
|
3614
|
+
{ id: "approve", kind: "human", human: { prompt: "approve", formKey: "bespoke-approval" }, emits: [{ name: "approval", type: "boolean" }] },
|
|
3615
|
+
],
|
|
3616
|
+
edges: [],
|
|
3617
|
+
};
|
|
3618
|
+
const r = await compileOk(graph);
|
|
3619
|
+
const el = elementForNode(r.bpmn, "approve");
|
|
3620
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3621
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3622
|
+
assert(io.includes(`source="=null" target="approval"`), "the human subProcess declares the bespoke form's fact-named 'approval' control node-local");
|
|
3623
|
+
});
|
|
3624
|
+
|
|
3625
|
+
test("node scope: a wait node localises its escalation controls + each declared emit source so two parallel timed-out waits never share a stale root value", async () => {
|
|
3626
|
+
// Regression guard (PR #863 Copilot review, thread r4200608648 "Localize wait escalation completion
|
|
3627
|
+
// variables to prevent cross-wait leakage"): a `wait` node's SLA-timeout escalation parks on the
|
|
3628
|
+
// generic form, whose completion writes `decision`/`value`/`escalationNote`, and on the escalate path
|
|
3629
|
+
// the `_lastAttempt` probe + any value-resume materialise the emit source (`detail`/`resolvedArtifact`/
|
|
3630
|
+
// `mergedSha`/`prCount`) — the interrupted probeLoop never ran its output mapping, so these do NOT
|
|
3631
|
+
// reach the wait subProcess scope on their own. Left at the shared ROOT they cross-publish between
|
|
3632
|
+
// parallel waits: a value-less completion on one wait reads a sibling's root `value`/emit and resumes
|
|
3633
|
+
// from the sibling's answer. Each wait subProcess must declare the escalation controls + each emit
|
|
3634
|
+
// source node-local, exactly as agent/connector/human do.
|
|
3635
|
+
const graph = {
|
|
3636
|
+
name: "two-waits",
|
|
3637
|
+
nodes: [
|
|
3638
|
+
{ id: "waitA", kind: "wait", wait: { kind: "command", target: "a.sh", match: { exitCode: 0 }, onTimeout: "escalate" }, emits: [{ name: "token", type: "string" }] },
|
|
3639
|
+
{ id: "waitB", kind: "wait", wait: { kind: "command", target: "b.sh", match: { exitCode: 0 }, onTimeout: "escalate" }, emits: [{ name: "token", type: "string" }] },
|
|
3640
|
+
],
|
|
3641
|
+
edges: [],
|
|
3642
|
+
};
|
|
3643
|
+
const r = await compileOk(graph);
|
|
3644
|
+
for (const nodeId of ["waitA", "waitB"]) {
|
|
3645
|
+
const el = elementForNode(r.bpmn, nodeId);
|
|
3646
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3647
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3648
|
+
// The escalation controls (the generic form's completion vars) are node-local...
|
|
3649
|
+
for (const v of ["decision", "value", "escalationNote"]) {
|
|
3650
|
+
assert(io.includes(`source="=null" target="${v}"`), `wait '${nodeId}' declares escalation control '${v}' node-local on the subProcess`);
|
|
3651
|
+
}
|
|
3652
|
+
// ...and the declared emit's SOURCE var (a non-mergedSha/prCount/non-artifact string emit sources
|
|
3653
|
+
// from the probe's `detail`) is node-local too, so a value-less timeout fail-closes to its OWN null.
|
|
3654
|
+
assert(io.includes(`source="=null" target="detail"`), `wait '${nodeId}' declares its 'token' emit source var (detail) node-local on the subProcess`);
|
|
3655
|
+
// And it still publishes its own node's value onward under the flat node-unique name.
|
|
3656
|
+
assert(io.includes(`target="${el}_token"`), `wait '${nodeId}' still publishes its emit as the node-unique ${el}_token`);
|
|
3657
|
+
}
|
|
3658
|
+
});
|
|
3659
|
+
|
|
3660
|
+
test("node scope: a wait node localises the generic escalation form's `note` control so a timed-out wait's operator note never leaks to root", async () => {
|
|
3661
|
+
// Regression guard (PR #863 Copilot review, thread r4200916073 "Declare wait form note as a local
|
|
3662
|
+
// variable"): a `wait` node's SLA-timeout escalation renders the GENERIC human form
|
|
3663
|
+
// (`delivery-human-generic` — a wait has no retryElement, so `escalationTaskLines` picks it over the
|
|
3664
|
+
// retry-capable `ESCALATION_FORM`). That form's controls are `value` + `note`, NOT the retry form's
|
|
3665
|
+
// `decision`/`value`/`escalationNote`. `value` is already in ESCALATION_LOCAL_VARS, but the generic
|
|
3666
|
+
// form's `note` control was NOT seeded node-local — so completing a timed-out wait with an operator
|
|
3667
|
+
// note propagated `note` to the shared ROOT, letting parallel waits overwrite one another's note and
|
|
3668
|
+
// exposing a wait-specific note to later jobs. Each wait subProcess must declare `note` node-local.
|
|
3669
|
+
const graph = {
|
|
3670
|
+
name: "two-waits-note",
|
|
3671
|
+
nodes: [
|
|
3672
|
+
{ id: "waitA", kind: "wait", wait: { kind: "command", target: "a.sh", match: { exitCode: 0 }, onTimeout: "escalate" }, emits: [{ name: "token", type: "string" }] },
|
|
3673
|
+
{ id: "waitB", kind: "wait", wait: { kind: "command", target: "b.sh", match: { exitCode: 0 }, onTimeout: "escalate" }, emits: [{ name: "token", type: "string" }] },
|
|
3674
|
+
],
|
|
3675
|
+
edges: [],
|
|
3676
|
+
};
|
|
3677
|
+
const r = await compileOk(graph);
|
|
3678
|
+
for (const nodeId of ["waitA", "waitB"]) {
|
|
3679
|
+
const el = elementForNode(r.bpmn, nodeId);
|
|
3680
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3681
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3682
|
+
// The generic escalation form's `note` control is node-local (alongside `value`).
|
|
3683
|
+
assert(io.includes(`source="=null" target="note"`), `wait '${nodeId}' declares the generic escalation form's 'note' control node-local on the subProcess`);
|
|
3684
|
+
assert(io.includes(`source="=null" target="value"`), `wait '${nodeId}' declares the generic escalation form's 'value' control node-local on the subProcess`);
|
|
3685
|
+
}
|
|
3686
|
+
});
|
|
3687
|
+
|
|
3688
|
+
test("node scope: a wait node localises an artifact emit's `resolvedArtifact` source and a `mergedSha`/`prCount` emit's own-named source (each distinct emit source, not the fact name)", async () => {
|
|
3689
|
+
// Thread r4200608648: factSourceVar maps a wait emit to a FIXED intermediate, never the fact's own
|
|
3690
|
+
// name — an artifact emit sources from `resolvedArtifact`, a `mergedSha`/`prCount` emit from its own
|
|
3691
|
+
// canonical probe bind. Each of those distinct source vars must be node-local, not just `detail`.
|
|
3692
|
+
for (const emit of [
|
|
3693
|
+
{ fact: { name: "built", type: "artifact" }, source: "resolvedArtifact" },
|
|
3694
|
+
{ fact: { name: "mergedSha", type: "string" }, source: "mergedSha" },
|
|
3695
|
+
{ fact: { name: "prCount", type: "number" }, source: "prCount" },
|
|
3696
|
+
]) {
|
|
3697
|
+
const graph = {
|
|
3698
|
+
name: `wait-${emit.source}`,
|
|
3699
|
+
nodes: [{ id: "w", kind: "wait", wait: { kind: "command", target: "c.sh", match: { exitCode: 0 }, onTimeout: "escalate" }, emits: [emit.fact] }],
|
|
3700
|
+
edges: [],
|
|
3701
|
+
};
|
|
3702
|
+
const r = await compileOk(graph);
|
|
3703
|
+
const el = elementForNode(r.bpmn, "w");
|
|
3704
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3705
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3706
|
+
assert(io.includes(`source="=null" target="${emit.source}"`), `a wait emitting '${emit.fact.name}' declares its '${emit.source}' emit source node-local`);
|
|
3707
|
+
}
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
test("node scope: an agent/connector node never declares the human-only scratch (no cross-kind leakage of HUMAN_RESULT_LOCAL_VARS)", async () => {
|
|
3711
|
+
// Fail-closed guard: the human scratch vars are human-specific; a service node must not declare them
|
|
3712
|
+
// node-local, so the localisation sets stay disjoint-by-kind.
|
|
3713
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3714
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3715
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3716
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3717
|
+
for (const v of ["humanEmitValue", "humanEmitArtifact", "humanOutcome", "humanNote"]) {
|
|
3718
|
+
assert(!io.includes(`target="${v}"`), `the agent node does not declare human-only '${v}'`);
|
|
3719
|
+
}
|
|
3720
|
+
});
|
|
3721
|
+
|
|
3722
|
+
test("connector timeout escalation is resumable with the connector's declared emits (Continue maps `value`; Retry clears them)", async () => {
|
|
3723
|
+
// Regression guard (PR #863 Copilot review, "Previously missed" — connector emits lost during timeout
|
|
3724
|
+
// escalation recovery): a connector has NO producer-contract gate (`contractGate === undefined`), so
|
|
3725
|
+
// keying the timeout-escalation resume off the gate dropped its `emits` — `emitMode` was "none", a
|
|
3726
|
+
// Continue mapped no `value`, and a Retry never cleared the stale emit. The resume must key off the
|
|
3727
|
+
// connector's OWN emits (emit source = the fact's own name).
|
|
3728
|
+
const graph = {
|
|
3729
|
+
name: "connector-resume",
|
|
3730
|
+
nodes: [
|
|
3731
|
+
{ id: "notify", kind: "connector", connector: { target: "slack:#a", payload: { pr: null } }, emits: [{ name: "ack", type: "string" }] },
|
|
3732
|
+
{ id: "use", kind: "agent", agent: { jobType: "j2" } },
|
|
3733
|
+
],
|
|
3734
|
+
edges: [{ from: "notify.ack", to: "use" }],
|
|
3735
|
+
};
|
|
3736
|
+
const r = await compileOk(graph);
|
|
3737
|
+
const el = elementForNode(r.bpmn, "notify");
|
|
3738
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "notify", "esc");
|
|
3739
|
+
// The typed-value field is presented (not forced to "none") and labelled with the declared emit…
|
|
3740
|
+
assert(esc.includes(`target="emitMode"`), "the connector timeout escalation carries an emitMode input");
|
|
3741
|
+
assert(esc.includes("typed") && !esc.includes('"none"'), "emitMode is 'typed' (the connector declares an emit), not 'none'");
|
|
3742
|
+
assert(esc.includes("ack (string)"), "the emit label names the connector's declared emit");
|
|
3743
|
+
// …and a Continue maps the operator's `value` onto the connector's emit-source var (the fact's name).
|
|
3744
|
+
assert(esc.includes(`target="ack"`), "a Continue resume publishes the operator value under the emit source var 'ack'");
|
|
3745
|
+
// The retry tail clears the connector's emit source var so a re-run never republishes a stale value.
|
|
3746
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3747
|
+
assert(reset.includes(`target="ack"`), "the retry reset clears the connector's 'ack' emit source var");
|
|
3748
|
+
});
|
|
3749
|
+
|
|
3750
|
+
test("#863 node scope: a connector's fixed result metadata (connectorOutcome/connectorDedupeKey/connectorDetail) is node-local and cleared on retry, so parallel connectors never overwrite one another's root-scoped result", async () => {
|
|
3751
|
+
// Regression guard (PR #863 Copilot review, thread r4180856788): the delivery-connector worker returns
|
|
3752
|
+
// `connectorOutcome`/`connectorDedupeKey`/`connectorDetail` on EVERY completion (app/deliveryConnector.ts).
|
|
3753
|
+
// Nano propagates job-completion vars to the nearest scope that defines the name, else ROOT — so without a
|
|
3754
|
+
// node-local declaration two parallel connectors overwrite ONE shared root result and an escalation /
|
|
3755
|
+
// downstream read sees whichever connector finished last. They must be declared node-local AND cleared on retry.
|
|
3756
|
+
const graph = {
|
|
3757
|
+
name: "two-connectors-result",
|
|
3758
|
+
nodes: [
|
|
3759
|
+
{ id: "notifyA", kind: "connector", connector: { target: "slack:#a", payload: { pr: null } } },
|
|
3760
|
+
{ id: "notifyB", kind: "connector", connector: { target: "slack:#b", payload: { pr: null } } },
|
|
3761
|
+
],
|
|
3762
|
+
edges: [],
|
|
3763
|
+
};
|
|
3764
|
+
const r = await compileOk(graph);
|
|
3765
|
+
for (const nodeId of ["notifyA", "notifyB"]) {
|
|
3766
|
+
const el = elementForNode(r.bpmn, nodeId);
|
|
3767
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3768
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3769
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3770
|
+
for (const v of ["connectorOutcome", "connectorDedupeKey", "connectorDetail"]) {
|
|
3771
|
+
assert(io.includes(`source="=null" target="${v}"`), `connector '${nodeId}' declares '${v}' node-local on the subProcess`);
|
|
3772
|
+
assert(new RegExp(`<zeebe:output source="=null" target="${v}" />`).test(reset), `connector '${nodeId}' clears '${v}' on retry`);
|
|
3773
|
+
}
|
|
3774
|
+
}
|
|
3775
|
+
});
|
|
3776
|
+
|
|
3777
|
+
test("#863 node scope: a connector retry reset never clears the non-local `note` (no null leak to shared root); an agent still clears it via its node-local result set", async () => {
|
|
3778
|
+
// Regression guard (PR #863 Copilot Medium "Previously missed", deliveryGraphCompiler.ts:2181):
|
|
3779
|
+
// `note` is a WORKER RESULT field declared node-local ONLY for an agent (it is in
|
|
3780
|
+
// AGENT_RESULT_LOCAL_VARS). A connector does NOT declare it node-local (CONNECTOR_RESULT_LOCAL_VARS
|
|
3781
|
+
// omits it; it is neither an escalation control nor a connector emit). An unconditional
|
|
3782
|
+
// `note = null` in the retry reset therefore lands at the SHARED ROOT for a connector — leaking null
|
|
3783
|
+
// across the node-isolation boundary and clobbering a parallel node's `note`. The reset must clear
|
|
3784
|
+
// `note` ONLY where it is actually node-local.
|
|
3785
|
+
const graph = {
|
|
3786
|
+
name: "connector-note-leak",
|
|
3787
|
+
nodes: [
|
|
3788
|
+
{ id: "notify", kind: "connector", connector: { target: "slack:#a", payload: { pr: null } } },
|
|
3789
|
+
],
|
|
3790
|
+
edges: [],
|
|
3791
|
+
};
|
|
3792
|
+
const rc = await compileOk(graph);
|
|
3793
|
+
const cel = elementForNode(rc.bpmn, "notify");
|
|
3794
|
+
const csub = rc.bpmn.slice(rc.bpmn.indexOf(`<bpmn:subProcess id="${cel}"`));
|
|
3795
|
+
const cio = csub.slice(0, csub.indexOf("</zeebe:ioMapping>"));
|
|
3796
|
+
assert(!cio.includes(`target="note"`), "the connector does not declare `note` node-local (so clearing it would write to root)");
|
|
3797
|
+
const creset = rc.bpmn.slice(rc.bpmn.indexOf(`id="${cel}_retry"`), rc.bpmn.indexOf("</bpmn:intermediateThrowEvent>", rc.bpmn.indexOf(`id="${cel}_retry"`)));
|
|
3798
|
+
assert(!creset.includes(`target="note"`), "the connector retry reset never clears `note` (would leak null to the shared root)");
|
|
3799
|
+
// The escalation controls ARE node-local for a connector, so the reset still clears them.
|
|
3800
|
+
for (const v of ["decision", "value", "escalationNote"]) {
|
|
3801
|
+
assert(new RegExp(`<zeebe:output source="=null" target="${v}" />`).test(creset), `the connector reset still clears the node-local escalation control '${v}'`);
|
|
3802
|
+
}
|
|
3803
|
+
// An agent, by contrast, DOES clear `note` on retry — it is node-local (AGENT_RESULT_LOCAL_VARS) —
|
|
3804
|
+
// and does so exactly once (the reset must not duplicate it).
|
|
3805
|
+
const ra = await compileOk(PRODUCER_GATE);
|
|
3806
|
+
const ael = elementForNode(ra.bpmn, "open");
|
|
3807
|
+
const areset = ra.bpmn.slice(ra.bpmn.indexOf(`id="${ael}_retry"`), ra.bpmn.indexOf("</bpmn:intermediateThrowEvent>", ra.bpmn.indexOf(`id="${ael}_retry"`)));
|
|
3808
|
+
const noteClears = areset.match(/<zeebe:output source="=null" target="note" \/>/g) ?? [];
|
|
3809
|
+
assertEquals(noteClears.length, 1, "the agent reset clears `note` exactly once (node-local, not duplicated)");
|
|
3810
|
+
});
|
|
3811
|
+
|
|
3812
|
+
test("node scope: an agent node never declares the connector-only result metadata (no cross-kind leakage of CONNECTOR_RESULT_LOCAL_VARS)", async () => {
|
|
3813
|
+
// Fail-closed guard: the connector result vars are connector-specific; an agent node must not declare
|
|
3814
|
+
// them node-local (they are not part of an agent's completion contract), so the two localisation sets
|
|
3815
|
+
// stay disjoint-by-kind.
|
|
3816
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3817
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3818
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3819
|
+
const io = sub.slice(0, sub.indexOf("</zeebe:ioMapping>"));
|
|
3820
|
+
for (const v of ["connectorOutcome", "connectorDedupeKey", "connectorDetail"]) {
|
|
3821
|
+
assert(!io.includes(`target="${v}"`), `the agent node does not declare connector-only '${v}'`);
|
|
3822
|
+
}
|
|
3823
|
+
});
|
|
3824
|
+
|
|
3825
|
+
test("preflight: the inner service task asserts its runner-seeded nodeInputs before any job exists (leaf, not the subProcess)", async () => {
|
|
3826
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3827
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3828
|
+
const task = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:serviceTask id="${el}_task"`));
|
|
3829
|
+
const taskIo = task.slice(0, task.indexOf("</bpmn:serviceTask>"));
|
|
3830
|
+
assert(taskIo.includes(`target="nodeInputsPresent"`), "the leaf task carries the preflight input");
|
|
3831
|
+
assert(taskIo.includes(`nodeInputs.${el} is missing`), "the assert names the missing config so the incident is actionable");
|
|
3832
|
+
const sub = r.bpmn.slice(r.bpmn.indexOf(`<bpmn:subProcess id="${el}"`));
|
|
3833
|
+
assert(!sub.slice(0, sub.indexOf("<bpmn:startEvent")).includes("nodeInputsPresent"), "never on the subProcess (nano-bpm#1334 skip)");
|
|
3834
|
+
});
|
|
3835
|
+
|
|
3836
|
+
test("retry-node: both escalations route decision=retry through a reset back to the task; the reset is not a scriptTask", async () => {
|
|
3837
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3838
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3839
|
+
// Nested topology (Option A): each escalation (`__esc` timeout + `__contract`) grows its OWN retry
|
|
3840
|
+
// gate (`${esc}Rg`) whose Retry branch funnels to the one shared reset; there is no single `${el}_rg`.
|
|
3841
|
+
const escEl = `delivery-human-task__${el}__esc`;
|
|
3842
|
+
const contractEl = `delivery-human-task__${el}__contract`;
|
|
3843
|
+
assert(r.bpmn.includes(`<bpmn:exclusiveGateway id="${escEl}Rg"`), "the timeout escalation grows a retry gateway");
|
|
3844
|
+
assert(r.bpmn.includes(`<bpmn:exclusiveGateway id="${contractEl}Rg"`), "the contract escalation grows a retry gateway");
|
|
3845
|
+
assert(r.bpmn.includes(`=${el}_retryRequested = true`), "it reads the node-unique retry decision");
|
|
3846
|
+
assert(r.bpmn.includes(`<bpmn:intermediateThrowEvent id="${el}_retry" name="Reset for retry">`), "the reset is a none throw event");
|
|
3847
|
+
for (const suffix of ["esc", "contract"]) {
|
|
3848
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", suffix);
|
|
3849
|
+
assert(esc.includes(`target="${el}_retryRequested"`), `the __${suffix} escalation publishes the retry decision`);
|
|
3850
|
+
}
|
|
3851
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3852
|
+
for (const v of ["status", "summary", "question", "pr", "decision", "note", "value", "escalationNote"]) {
|
|
3853
|
+
assert(new RegExp(`target="${v}"`).test(reset), `the reset clears '${v}' from the previous attempt`);
|
|
3854
|
+
}
|
|
3855
|
+
assert(reset.includes(`target="appendPrompt"`), "the reset passes the operator note to the agent");
|
|
3856
|
+
// The guidance paragraph reads the escalation-specific `escalationNote` control — NEVER the plain
|
|
3857
|
+
// `note`, which is a plan.md worker RESULT field propagated into the same subProcess scope (PR #863
|
|
3858
|
+
// "Previously missed", deliveryGraphCompiler.ts:2156).
|
|
3859
|
+
const appendLine = reset.split("\n").find((l) => l.includes(`target="appendPrompt"`));
|
|
3860
|
+
assert(appendLine?.includes("escalationNote"), "the guidance reads the escalationNote control");
|
|
3861
|
+
assert(!/[( ]note[ )]/.test(appendLine.replaceAll("escalationNote", "")), "the guidance never reads the worker-result `note`");
|
|
3862
|
+
});
|
|
3863
|
+
|
|
3864
|
+
test("retry-node: the reset clears the FULL declared agent result set, not only the five status fields", async () => {
|
|
3865
|
+
// Regression guard (PR #863 Copilot "Previously missed"): the reset hardcoded only
|
|
3866
|
+
// status/summary/question/output/error, while every field in AGENT_RESULT_LOCAL_VARS is declared
|
|
3867
|
+
// node-local and persists across attempts. A retried worker that omits an optional result field
|
|
3868
|
+
// (transcriptUrl, agentCheckpoint, a PR alias, exitCode, …) would otherwise let the previous
|
|
3869
|
+
// attempt's value republish downstream (stale transcript/PR) or surface in the next escalation. The
|
|
3870
|
+
// reset must clear the whole declared set — a class fix, not just the five named fields.
|
|
3871
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3872
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3873
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3874
|
+
// These are declared in AGENT_RESULT_LOCAL_VARS but are NOT emits of the fixture and NOT among the
|
|
3875
|
+
// old five-field hardcode, so each is a field the old reset left stale across a retry.
|
|
3876
|
+
for (const v of ["transcriptUrl", "agentCheckpoint", "prUrl", "pullRequest", "branch", "commits", "exitCode", "next_steps", "issue", "completed", "pushed", "truncated", "delta"]) {
|
|
3877
|
+
assert(new RegExp(`<zeebe:output source="=null" target="${v}" />`).test(reset), `the reset clears '${v}' from the previous attempt`);
|
|
3878
|
+
}
|
|
3879
|
+
});
|
|
3880
|
+
|
|
3881
|
+
test("retry-node: the reset re-derives appendPrompt from the runner-seeded nodeInputs baseline, so retry notes never accumulate", async () => {
|
|
3882
|
+
// Regression guard (PR #863 adversarial review): the reset previously built on the LIVE
|
|
3883
|
+
// `appendPrompt`, which the subProcess seeds from `nodeInputs` only at ENTRY — so a second retry's
|
|
3884
|
+
// note appended onto the first retry's already-appended prompt, growing one stale "Operator
|
|
3885
|
+
// guidance…" paragraph per retry. The reset must read ONLY the runner-seeded baseline.
|
|
3886
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3887
|
+
const el = elementForNode(r.bpmn, "open");
|
|
3888
|
+
const reset = r.bpmn.slice(r.bpmn.indexOf(`id="${el}_retry"`), r.bpmn.indexOf("</bpmn:intermediateThrowEvent>", r.bpmn.indexOf(`id="${el}_retry"`)));
|
|
3889
|
+
const appendLine = reset.split("\n").find((l) => l.includes(`target="appendPrompt"`));
|
|
3890
|
+
assert(appendLine, "the reset writes appendPrompt");
|
|
3891
|
+
assert(appendLine.includes(`nodeInputs.${el}.appendPrompt`), "it builds on the runner-seeded baseline, not the live (note-carrying) appendPrompt");
|
|
3892
|
+
assert(!/is defined\(appendPrompt\)/.test(appendLine), "it never reads the live appendPrompt var");
|
|
3893
|
+
});
|
|
3894
|
+
|
|
3895
|
+
test("escalation form structure: the delivery-escalation form has a `decision` select defaulting to `continue` with `continue`/`retry` options (so Retry is reachable in the Tasks UI)", async () => {
|
|
3896
|
+
// Regression guard (PR #863 Copilot "Previously missed", delivery-escalation.form:16): the compiled
|
|
3897
|
+
// tasks only reference the form by ID and the E2E completes tasks programmatically, so renaming/
|
|
3898
|
+
// removing the `decision` key or its `retry` option would leave the suite green yet make Retry
|
|
3899
|
+
// unavailable in the Tasks UI. Parse the actual form and assert its select key, values, and default —
|
|
3900
|
+
// the same contract the compiler's retry gateway reads (`decision = "retry"`).
|
|
3901
|
+
const { readFileSync } = await import("node:fs");
|
|
3902
|
+
const { fileURLToPath } = await import("node:url");
|
|
3903
|
+
const formPath = fileURLToPath(new URL("../resources/forms/delivery-escalation.form", import.meta.url));
|
|
3904
|
+
const form = JSON.parse(readFileSync(formPath, "utf8")) as {
|
|
3905
|
+
components: { type: string; key?: string; defaultValue?: string; values?: { value: string }[] }[];
|
|
3906
|
+
};
|
|
3907
|
+
const select = form.components.find((c) => c.type === "select" && c.key === "decision");
|
|
3908
|
+
assert(select, "the form exposes a `decision` select the retry gateway keys off");
|
|
3909
|
+
assertEquals(select?.defaultValue, "continue", "the select defaults to `continue` (safe non-retry default)");
|
|
3910
|
+
const optionValues = (select?.values ?? []).map((v) => v.value).sort();
|
|
3911
|
+
assertEquals(optionValues.join(","), "continue,retry", "the select offers exactly `continue` and `retry`");
|
|
3912
|
+
});
|
|
3913
|
+
|
|
3914
|
+
test("escalation form structure: the delivery-escalation value field states its single-emit boundary (a zero/multi-emit entry is discarded)", async () => {
|
|
3915
|
+
// Regression guard (PR #863 Copilot "Previously missed", delivery-escalation.form:32): the compiler
|
|
3916
|
+
// maps the operator's `value` onto an emit source ONLY when the node declares exactly one emit
|
|
3917
|
+
// (`resumableEmit` — with zero emits there is nothing to resume; with >1 a single value cannot
|
|
3918
|
+
// satisfy multiple distinct typed facts without corrupting them). The Tasks surface seeds no form
|
|
3919
|
+
// variables, so a `conditional.hide` on `emitMode` cannot fire (issue #772) — the static form cannot
|
|
3920
|
+
// disable the field per node. Its copy must therefore STATE the cardinality boundary, so an operator
|
|
3921
|
+
// never enters a value that is silently discarded.
|
|
3922
|
+
const { readFileSync } = await import("node:fs");
|
|
3923
|
+
const { fileURLToPath } = await import("node:url");
|
|
3924
|
+
const formPath = fileURLToPath(new URL("../resources/forms/delivery-escalation.form", import.meta.url));
|
|
3925
|
+
const form = JSON.parse(readFileSync(formPath, "utf8")) as {
|
|
3926
|
+
components: { key?: string; label?: string; description?: string }[];
|
|
3927
|
+
};
|
|
3928
|
+
const value = form.components.find((c) => c.key === "value");
|
|
3929
|
+
assert(value, "the escalation form keeps its single `value` field");
|
|
3930
|
+
const copy = `${value?.label ?? ""}\n${value?.description ?? ""}`;
|
|
3931
|
+
assert(/exactly one (emitted )?fact/i.test(copy), "the value field copy states the single-emit boundary");
|
|
3932
|
+
assert(/more than one/i.test(copy) && /[Rr]etry/.test(copy), "the copy steers a multi-emit resolution to Retry (its value would be discarded)");
|
|
3933
|
+
assert(/emits nothing/i.test(copy), "the copy tells a zero-emit step to leave the field blank");
|
|
3934
|
+
});
|
|
3935
|
+
|
|
3936
|
+
test("human form structure: the delivery-human-generic value field states its single-value boundary generically (human node AND wait-gate escalation)", async () => {
|
|
3937
|
+
// Regression guard (PR #863 review — "Use generic wording for wait-gate escalation forms"): the
|
|
3938
|
+
// generic form is rendered for the scheduled human node (single value-emit) AND for every NON-retry
|
|
3939
|
+
// escalation — notably a timed-out WAIT gate (`escalationTaskLines` with no `retryElement`). A wait
|
|
3940
|
+
// gate may emit an ARTIFACT (this very `value` field is then mapped onto `resolvedArtifact` via
|
|
3941
|
+
// `factSourceVar`) or declare MULTIPLE facts (multi-emit waits are allowed, not rejected). So the copy
|
|
3942
|
+
// must NOT assert the scheduled-human-node-only restrictions the old wording did ("a step that
|
|
3943
|
+
// publishes an artifact uses a dedicated form", "a step that must emit several facts is rejected at
|
|
3944
|
+
// authoring time") — those are false for the wait-gate case that shares this form. The copy must
|
|
3945
|
+
// describe the field generically and still tell a zero-emit step to leave it blank. Unlike the
|
|
3946
|
+
// escalation form there is no Retry SELECT here, so the copy must NOT steer to a "Retry" control.
|
|
3947
|
+
const { readFileSync } = await import("node:fs");
|
|
3948
|
+
const { fileURLToPath } = await import("node:url");
|
|
3949
|
+
const formPath = fileURLToPath(new URL("../resources/forms/delivery-human-generic.form", import.meta.url));
|
|
3950
|
+
const form = JSON.parse(readFileSync(formPath, "utf8")) as {
|
|
3951
|
+
components: { key?: string; label?: string; description?: string }[];
|
|
3952
|
+
};
|
|
3953
|
+
const value = form.components.find((c) => c.key === "value");
|
|
3954
|
+
assert(value, "the generic human form keeps its single `value` field");
|
|
3955
|
+
const copy = `${value?.label ?? ""}\n${value?.description ?? ""}`;
|
|
3956
|
+
assert(/single value this (task|step) hands forward/i.test(copy), "the value field copy states the single-value boundary generically");
|
|
3957
|
+
assert(/wait gate/i.test(copy) && /artifact handle/i.test(copy), "the copy covers the shared wait-gate escalation case, where the value may be an artifact handle");
|
|
3958
|
+
assert(/blank when the step emits nothing|leave it blank/i.test(copy), "the copy tells a zero-emit step to leave the field blank");
|
|
3959
|
+
assert(!/dedicated form with a resolved-artifact field/i.test(copy), "the copy must not assert the human-node-only 'artifact uses a dedicated form' carve-out (false for a wait-gate artifact)");
|
|
3960
|
+
assert(!/[Rr]etry/.test(copy), "the generic form has no Retry select, so its copy must not steer to one");
|
|
3961
|
+
});
|
|
3962
|
+
|
|
3963
|
+
test("escalation forms: service-node escalations attach delivery-escalation (retry select); human + wait-gate tasks keep the generic form", async () => {
|
|
3964
|
+
// Regression guard (PR #863 adversarial review): the retry Resolution select must render ONLY where
|
|
3965
|
+
// retry semantics exist. The wait-gate escalation (no retryElement) and the plain human node share
|
|
3966
|
+
// the generic form; the service timeout/contract escalations carry the select-bearing form.
|
|
3967
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3968
|
+
for (const suffix of ["esc", "contract"]) {
|
|
3969
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", suffix);
|
|
3970
|
+
assert(esc.includes('formId="delivery-escalation"'), `the __${suffix} escalation attaches the retry-capable form`);
|
|
3971
|
+
assert(!esc.includes('formId="delivery-human-generic"'), `the __${suffix} escalation no longer shares the generic human form`);
|
|
3972
|
+
}
|
|
3973
|
+
const waitGraph = await compileOk({
|
|
3974
|
+
nodes: [
|
|
3975
|
+
{ id: "open", kind: "agent", agent: { jobType: "senior:open" }, emits: [{ name: "pr", type: "pr" }] },
|
|
3976
|
+
{ id: "merged", kind: "wait", wait: { kind: "pr", target: "open.pr", match: { prState: "merged" } } },
|
|
3977
|
+
],
|
|
3978
|
+
edges: [{ from: "open.pr", to: "merged" }],
|
|
3979
|
+
});
|
|
3980
|
+
const waitEsc = escBlockForNodeSuffix(waitGraph.bpmn, "merged", "esc__wait");
|
|
3981
|
+
assert(waitEsc.includes('formId="delivery-human-generic"'), "the wait-gate escalation keeps the generic form (no retry semantics)");
|
|
3982
|
+
assert(!waitEsc.includes('formId="delivery-escalation"'), "no retry select on a wait-gate escalation");
|
|
3983
|
+
const humanGraph = await compileOk({ nodes: [{ id: "h", kind: "human", human: { prompt: "approve" } }], edges: [] });
|
|
3984
|
+
const humanTask = humanGraph.bpmn.slice(humanGraph.bpmn.indexOf("<bpmn:userTask"), humanGraph.bpmn.indexOf("</bpmn:userTask>"));
|
|
3985
|
+
// A no-emit human node is a pure acknowledgement — resolveHumanForm selects the ack form (no value
|
|
3986
|
+
// field), not the generic typed-emit form (PR #863 thread deliveryGraphCompiler.ts:1859).
|
|
3987
|
+
assert(humanTask.includes('formId="delivery-human-ack"'), "a no-emit human node uses the acknowledgement form");
|
|
3988
|
+
assert(!humanTask.includes('formId="delivery-escalation"'), "no retry select on a plain human step");
|
|
3989
|
+
});
|
|
3990
|
+
|
|
3991
|
+
test("contract escalation context: carries the agent's own report, question, error, transcript and how to resolve", async () => {
|
|
3992
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
3993
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
3994
|
+
for (const s of ["Agent report: ", "Agent question: ", "Error: ", "Transcript: ", "Retry this step", "Continue"]) {
|
|
3995
|
+
assert(esc.includes(s), `the context includes '${s}'`);
|
|
3996
|
+
}
|
|
3997
|
+
});
|