@nanobpm/nano-workforce 0.201.0 → 0.201.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +153 -0
  3. package/app/agentActivationLedger.test.ts +109 -0
  4. package/app/agentActivationLedger.ts +144 -0
  5. package/app/agentActivationsPoll.test.ts +180 -0
  6. package/app/agentCompletion.test.ts +52 -0
  7. package/app/agentCompletion.ts +20 -5
  8. package/app/agentSlaEscalation.test.ts +137 -0
  9. package/app/agentSlaEscalation.ts +169 -0
  10. package/app/agentSlaMarkerGuard.test.ts +172 -0
  11. package/app/contracts.ts +9 -0
  12. package/app/deliveryGraph.test.ts +251 -0
  13. package/app/deliveryGraph.ts +334 -1
  14. package/app/deliveryGraphCompiler.test.ts +1345 -30
  15. package/app/deliveryGraphCompiler.ts +882 -240
  16. package/app/deliveryGraphDeploy.test.ts +472 -3
  17. package/app/deliveryGraphRun.ts +2 -2
  18. package/app/deliveryHuman.test.ts +59 -5
  19. package/app/deliveryHuman.ts +115 -3
  20. package/app/deliveryRunner.test.ts +3 -3
  21. package/app/mergeLoopBehaviour.test.ts +46 -1
  22. package/app/redactText.ts +88 -0
  23. package/app/service.ts +110 -2
  24. package/db/migrations/119_agent_job_activations.sql +50 -0
  25. package/e2e/agent-sla-boundary.e2e.ts +51 -8
  26. package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
  27. package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
  28. package/e2e/delivery-graph.e2e.ts +11 -7
  29. package/package.json +1 -1
  30. package/resources/forms/delivery-escalation.form +41 -0
  31. package/resources/forms/delivery-human-generic.form +4 -3
  32. package/resources/processes/convergence-loop.bpmn +10 -0
  33. package/resources/processes/implement-cell.bpmn +4 -1
  34. package/resources/processes/merge-cell.bpmn +4 -1
  35. package/resources/processes/merge-loop.bpmn +7 -0
  36. package/resources/processes/plan-fanout.bpmn +5 -0
  37. package/resources/processes/retro.bpmn +5 -2
  38. package/test/derivation-parity/flows.ts +5 -3
  39. package/workers/conformance-record/worker.ts +15 -1
  40. package/workers/persist-escalation/worker.ts +22 -3
  41. package/workers/record-feature-escalation/worker.ts +28 -12
  42. package/workers/record-trial-merge/worker.ts +16 -1
@@ -30,6 +30,7 @@ import {
30
30
  transcriptUrlForJob,
31
31
  } from "./agentic/transcript-url.ts";
32
32
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
33
+ import { isDeliveryEscalationTwin } from "./deliveryHuman.ts";
33
34
 
34
35
  /** A graph exercising the full node-kind matrix: `agent` (a named `senior:*` job), `wait` (the
35
36
  * `pr.readiness-probe` poll gate), `human` (a user task), and `connector` (the delivery-connector
@@ -96,7 +97,7 @@ test("deploy+advance: a well-formed graph deploys through the real engine and ev
96
97
  // human node's user task deployed and is completable, not just that the instance ended.
97
98
  assertEquals(humanTasks.length, 1, `expected exactly one human user task, saw ${JSON.stringify(humanTasks)}`);
98
99
  assert(
99
- humanTasks[0].startsWith("delivery-human-task__") && !humanTasks[0].endsWith("__esc"),
100
+ humanTasks[0].startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(humanTasks[0]),
100
101
  `expected a human node task, saw ${humanTasks[0]}`,
101
102
  );
102
103
  } finally {
@@ -159,7 +160,7 @@ test("deploy+advance: a stalled service node escalates on its node-timeout bound
159
160
  `the escalation task must have been driven, saw ${JSON.stringify(completed)}`,
160
161
  );
161
162
  assert(
162
- completed.some((id) => id.startsWith("delivery-human-task__") && !id.endsWith("__esc")),
163
+ completed.some((id) => id.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(id)),
163
164
  `the downstream human task must have been driven, saw ${JSON.stringify(completed)}`,
164
165
  );
165
166
  } finally {
@@ -529,7 +530,7 @@ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge
529
530
  }
530
531
  }
531
532
  assert(
532
- parked.startsWith("delivery-human-task__") && !parked.endsWith("__esc"),
533
+ parked.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(parked),
533
534
  `the missing outcome must park on the escalate human leaf, saw ${JSON.stringify(parked)}`,
534
535
  );
535
536
  assert(!doneRan, "the guarded (missing) path must NOT run the `done` leaf");
@@ -538,3 +539,471 @@ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge
538
539
  }
539
540
  }
540
541
  });
542
+
543
+ // ── #863 deploy+route: a single-BOOLEAN human emit publishes a REAL FEEL boolean (not null) ─────────
544
+ // The compiler tests above assert only the SHAPE of the `coerceFactValueFeel` output string; they do
545
+ // NOT evaluate it, so they stayed green against a coercion defeated by a FEEL operator-precedence bug —
546
+ // `selectExpr` returns a BARE `if … then … else null` and the coercion embedded it as `rawExpr != null`,
547
+ // where FEEL's GREEDY `else` arm swallowed the `!= null and matches(…)` guard, collapsing the whole
548
+ // condition to the operator's raw entry and publishing NULL (adversarial finding, round 15; empirically
549
+ // `humanEmitValue`/`<el>_approval` came back null for `{approval:"true"}`). This drives it END TO END on
550
+ // the real engine: a human node emitting a single `boolean` feeds a guarded split comparing the fact
551
+ // `= true`; completing the task with the truthy text must publish a real `true` so the TRUE branch runs
552
+ // (a null-publishing coercion would route the `default` branch instead). Covers the bespoke
553
+ // (fact-named-field) AND generic (`value`-captured) forms — both go through the same coercion.
554
+ async function driveBooleanHumanGuard(opts: { bespoke: boolean; entry: Record<string, string>; emitName?: string }): Promise<{
555
+ state: string;
556
+ yesRan: boolean;
557
+ noRan: boolean;
558
+ task: string;
559
+ }> {
560
+ const engine = await createWasmEngineClient();
561
+ try {
562
+ let yesRan = false;
563
+ let noRan = false;
564
+ await engine.registerWorker("senior:yes", async () => {
565
+ yesRan = true;
566
+ return {};
567
+ });
568
+ await engine.registerWorker("senior:no", async () => {
569
+ noRan = true;
570
+ return {};
571
+ });
572
+ const emitName = opts.emitName ?? "approval";
573
+ const graph: DeliveryGraph = {
574
+ name: "boolean human guard",
575
+ nodes: [
576
+ {
577
+ id: "gate",
578
+ kind: "human",
579
+ human: opts.bespoke ? { prompt: "approve?", formKey: "bespoke-approval" } : { prompt: "approve?" },
580
+ emits: [{ name: emitName, type: "boolean" }],
581
+ },
582
+ { id: "yes", kind: "agent", agent: { jobType: "senior:yes" } },
583
+ { id: "no", kind: "agent", agent: { jobType: "senior:no" } },
584
+ ],
585
+ edges: [
586
+ { from: "gate", to: "yes", when: `gate.${emitName}`, equals: true },
587
+ { from: "gate", to: "no", default: true },
588
+ ],
589
+ };
590
+ // A long SLA so the human node's escalation boundary never fires during the drive.
591
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
592
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
593
+ const key = run.handle.processInstanceKey;
594
+
595
+ let state = "?";
596
+ let task = "";
597
+ for (let round = 0; round < MAX_ROUNDS; round++) {
598
+ await engine.drain();
599
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
600
+ assert(pi, `no process instance snapshot for ${key}`);
601
+ state = pi.state ?? "?";
602
+ if (state === "COMPLETED" || state === "TERMINATED") break;
603
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
604
+ assert(open.length > 0, `instance is ${state} with no open user task — the guarded split never advanced`);
605
+ for (const t of open) {
606
+ task = t.elementId ?? "?";
607
+ await engine.completeUserTask(t.userTaskKey, opts.entry);
608
+ }
609
+ }
610
+ return { state, yesRan, noRan, task };
611
+ } finally {
612
+ await engine.close();
613
+ }
614
+ }
615
+
616
+ test("#863 deploy+route: a BESPOKE single-boolean human form publishes a REAL `true` (not null) so the `= true` guard routes the TRUE branch", async () => {
617
+ // The bespoke form captures under the FACT's own name (`approval`), so the operator's entry arrives as
618
+ // `{approval:"true"}`; the coercion must publish the boolean `true`, not null.
619
+ const r = await driveBooleanHumanGuard({ bespoke: true, entry: { approval: "true" } });
620
+ assert(r.task.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(r.task), `expected the human task, saw ${r.task}`);
621
+ assertEquals(r.state, "COMPLETED", "the graph must run to a COMPLETED instance");
622
+ assert(r.yesRan, "a truthy bespoke boolean entry must publish a real `true` so the `= true` guard routes the TRUE branch (a null-publishing coercion would take the default)");
623
+ assert(!r.noRan, "the default branch must NOT run when the boolean coerces to true");
624
+ });
625
+
626
+ test("#863 deploy+route: a GENERIC single-boolean human form ALSO publishes a real `true` (class sweep — same coercion, `value`-captured)", async () => {
627
+ // The generic form captures under the canonical `value` control, so the operator's entry arrives as
628
+ // `{value:"true"}`; the SAME coercion path must still publish a real `true`.
629
+ const r = await driveBooleanHumanGuard({ bespoke: false, entry: { value: "true" } });
630
+ assertEquals(r.state, "COMPLETED", "the graph must run to a COMPLETED instance");
631
+ assert(r.yesRan, "a truthy generic boolean entry must publish a real `true` so the `= true` guard routes the TRUE branch");
632
+ assert(!r.noRan, "the default branch must NOT run when the boolean coerces to true");
633
+ });
634
+
635
+ // ── #863 deploy+route: a single-NUMBER human emit coerces END TO END without incidenting on a blank ──
636
+ // The compiler tests assert only the SHAPE of `coerceFactValueFeel`'s number arm
637
+ // (`number(trim(string(value)))`); they never EVALUATE it, so they stayed green against a FEEL hazard.
638
+ // Round 23 aligned the number bind with the gate's `trim(string(...))` normalization. But `trim` is NOT
639
+ // null-safe, and `operand != null` does NOT prove the operand is STRINGABLE: when the emit's name collides
640
+ // with a FEEL BUILTIN (`count`, `sum`, `min`, …), a BLANK human form leaves that variable unset, so the
641
+ // `if (is defined(count) and count != null) then count else …` selection operand resolves to the builtin
642
+ // FUNCTION (`is defined(count)` is true for the builtin, and the function ≠ null). `trim(string(<function>))`
643
+ // then THROWS (`trim: expected a string, got null`), raising an io-mapping INCIDENT that parks the instance
644
+ // ACTIVE instead of publishing null and completing (adversarial finding, round 23; the pre-regression
645
+ // `number(rawExpr)` folded the same builtin operand to null cleanly). The fix NULL-SAFES the bind with a
646
+ // `string(rawExpr) != null` guard that short-circuits the trim for any non-stringable operand. This drives
647
+ // the number coercion END TO END on the real engine for a reserved-name (`count`) blank entry (must publish
648
+ // null → the `= 42` guard takes the default) and a padded `" 42 "` entry (must coerce the trimmed text to
649
+ // the real number → the TRUE branch). Covers the bespoke (fact-named-field) AND generic (`value`-captured)
650
+ // forms — both go through the same coercion.
651
+ async function driveNumberHumanGuard(opts: { bespoke: boolean; entry: Record<string, string> }): Promise<{
652
+ state: string;
653
+ yesRan: boolean;
654
+ noRan: boolean;
655
+ task: string;
656
+ }> {
657
+ const engine = await createWasmEngineClient();
658
+ try {
659
+ let yesRan = false;
660
+ let noRan = false;
661
+ await engine.registerWorker("senior:yes", async () => {
662
+ yesRan = true;
663
+ return {};
664
+ });
665
+ await engine.registerWorker("senior:no", async () => {
666
+ noRan = true;
667
+ return {};
668
+ });
669
+ const graph: DeliveryGraph = {
670
+ name: "number human guard",
671
+ nodes: [
672
+ {
673
+ id: "gate",
674
+ kind: "human",
675
+ human: opts.bespoke ? { prompt: "how many?", formKey: "bespoke-count" } : { prompt: "how many?" },
676
+ emits: [{ name: "count", type: "number" }],
677
+ },
678
+ { id: "yes", kind: "agent", agent: { jobType: "senior:yes" } },
679
+ { id: "no", kind: "agent", agent: { jobType: "senior:no" } },
680
+ ],
681
+ edges: [
682
+ { from: "gate", to: "yes", when: "gate.count", equals: 42 },
683
+ { from: "gate", to: "no", default: true },
684
+ ],
685
+ };
686
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
687
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
688
+ const key = run.handle.processInstanceKey;
689
+
690
+ let state = "?";
691
+ let task = "";
692
+ for (let round = 0; round < MAX_ROUNDS; round++) {
693
+ await engine.drain();
694
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
695
+ assert(pi, `no process instance snapshot for ${key}`);
696
+ state = pi.state ?? "?";
697
+ if (state === "COMPLETED" || state === "TERMINATED") break;
698
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
699
+ // A blank-number io-mapping INCIDENT parks the instance ACTIVE with NO open user task — this is the
700
+ // exact failure the null-unsafe `number(trim(string(null)))` produced, and this assert catches it.
701
+ assert(open.length > 0, `instance is ${state} with no open user task — the number coercion incidented instead of publishing a value`);
702
+ for (const t of open) {
703
+ task = t.elementId ?? "?";
704
+ await engine.completeUserTask(t.userTaskKey, opts.entry);
705
+ }
706
+ }
707
+ return { state, yesRan, noRan, task };
708
+ } finally {
709
+ await engine.close();
710
+ }
711
+ }
712
+
713
+ test("#863 deploy+route: a BESPOKE single-number human form with a FEEL-builtin name (`count`) completed BLANK publishes null and COMPLETES (no io-mapping incident)", async () => {
714
+ // Value absent + a builtin name → the selection operand resolves to the `count` builtin FUNCTION, so the
715
+ // coercion's then-arm must NOT evaluate `trim(string(<function>))` (which throws); the `string(rawExpr)
716
+ // != null` guard short-circuits it, the instance publishes null and routes the default.
717
+ const r = await driveNumberHumanGuard({ bespoke: true, entry: {} });
718
+ assert(r.task.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(r.task), `expected the human task, saw ${r.task}`);
719
+ assertEquals(r.state, "COMPLETED", "a blank bespoke number entry must publish null and run to a COMPLETED instance (not park on an io-mapping incident)");
720
+ assert(!r.yesRan, "a blank number entry publishes null, so the `= 42` guard must NOT run the TRUE branch");
721
+ assert(r.noRan, "a blank number entry publishes null, so the default branch runs");
722
+ });
723
+
724
+ test("#863 deploy+route: a GENERIC single-number human form completed BLANK ALSO publishes null and COMPLETES (class sweep)", async () => {
725
+ const r = await driveNumberHumanGuard({ bespoke: false, entry: {} });
726
+ assertEquals(r.state, "COMPLETED", "a blank generic number entry must publish null and run to a COMPLETED instance");
727
+ assert(!r.yesRan, "a blank number entry publishes null, so the `= 42` guard must NOT run the TRUE branch");
728
+ assert(r.noRan, "a blank number entry publishes null, so the default branch runs");
729
+ });
730
+
731
+ test("#863 deploy+route: a BESPOKE single-number human form coerces a PADDED `\" 42 \"` to the real number so the `= 42` guard routes TRUE", async () => {
732
+ // The gate accepts `" 42 "` (it trims), so the coercion must parse the SAME trimmed text to 42 — a
733
+ // bare `number(" 42 ")` would yield null and wrongly take the default, breaking "gate accepts ⇒ bind".
734
+ const r = await driveNumberHumanGuard({ bespoke: true, entry: { count: " 42 " } });
735
+ assertEquals(r.state, "COMPLETED", "the graph must run to a COMPLETED instance");
736
+ assert(r.yesRan, "a padded `\" 42 \"` entry must coerce to the real number 42 so the `= 42` guard routes the TRUE branch");
737
+ assert(!r.noRan, "the default branch must NOT run when the number coerces to 42");
738
+ });
739
+
740
+ test("#863 deploy+route: a BESPOKE single-boolean human form with a FEEL-builtin name (`count`) completed BLANK publishes null and COMPLETES (class sweep — same null-unsafe-normalization hazard as the number arm)", async () => {
741
+ // The boolean arm shares the hazard: `lower case(trim(string(<count builtin>)))` throws on a blank
742
+ // builtin-named emit. The `string(rawExpr) != null` guard short-circuits it, so the instance publishes
743
+ // null (a blank is not `true`) and routes the default rather than parking on an io-mapping incident.
744
+ const r = await driveBooleanHumanGuard({ bespoke: true, entry: {}, emitName: "count" });
745
+ assert(r.task.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(r.task), `expected the human task, saw ${r.task}`);
746
+ assertEquals(r.state, "COMPLETED", "a blank builtin-named boolean entry must publish null and run to a COMPLETED instance (not park on an io-mapping incident)");
747
+ assert(!r.yesRan, "a blank boolean entry publishes null, so the `= true` guard must NOT run the TRUE branch");
748
+ assert(r.noRan, "a blank boolean entry publishes null, so the default branch runs");
749
+ });
750
+
751
+ // ── #863 deploy+route: a TEXT (`string`) / `artifact` single emit named after a FEEL builtin ─────────
752
+ // The number/boolean coercers reject a non-stringable selection operand, but the TEXT types
753
+ // (`string`/`version`/`url`/`pr`) pass `selectExpr`'s result through verbatim and the artifact source is
754
+ // NEVER coerced. The builtin-shadow hazard is a blank explicit form whose fact name collides with a FEEL
755
+ // builtin: `is defined(count) and count != null` is TRUE for the `count` builtin FUNCTION, so the
756
+ // fact-named candidate `then count` selects a FUNCTION for those fact types. The fix guards that candidate
757
+ // in `selectExpr` with `string(<factName>) != null`, which folds a builtin to null at the selection step
758
+ // for EVERY fact type (Copilot review #863, "Guard fact names that shadow FEEL builtins").
759
+ //
760
+ // IMPORTANT — what gates a GUARD REGRESSION vs what these engine tests prove. On the pinned WASM engine a
761
+ // function-valued selection ALREADY folds to null before it is published (a FEEL variable binding cannot
762
+ // hold a function), so the engine-observable outcome — COMPLETED, default routing, AND the published
763
+ // `humanEmitValue`/`humanEmitArtifact` (null for a blank form) — is IDENTICAL with or without the guard.
764
+ // The red-before/green-after gate against removing the guard is therefore the COMPILER-level assertion in
765
+ // `deliveryGraphCompiler.test.ts` (it regex-matches `… and string(<fact>) != null …` in the generated
766
+ // FEEL and goes red the moment the guard is dropped). These end-to-end engine tests instead pin the
767
+ // observable PUBLISHED-value contract — a blank builtin-named emit publishes null (not a function, which
768
+ // would incident or corrupt the fact on an engine that did NOT fold), and the guard does NOT drop a real
769
+ // captured text value (`approved` still selects and routes TRUE). Both layers are needed: the compiler
770
+ // test locks the guard in, the engine test proves the guarded expression yields the intended value.
771
+ async function driveStringHumanGuard(opts: { entry: Record<string, string>; emitName: string }): Promise<{
772
+ state: string;
773
+ yesRan: boolean;
774
+ noRan: boolean;
775
+ task: string;
776
+ published: unknown;
777
+ }> {
778
+ const engine = await createWasmEngineClient();
779
+ try {
780
+ let yesRan = false;
781
+ let noRan = false;
782
+ // The selected value is published as the node's flat `<el>_<fact>` emit (`n0_<emitName>` — `gate`
783
+ // sorts first, so its element is `n0`) into PROCESS scope, which is what a downstream worker (and the
784
+ // guarded split's `n0_<fact> = …` condition) actually observes. Capture THAT, not the node-local
785
+ // `humanEmitValue` scratch — since #863 (thread r4199949849) the scratch is localised to the human
786
+ // subProcess to stop cross-node leakage, so it never reaches a downstream worker. Asserting the
787
+ // published emit is the real PUBLISHED-value contract, not merely completion.
788
+ const emitVar = `n0_${opts.emitName}`;
789
+ let published: unknown = "<<never published>>";
790
+ const capture = (job: { variables?: Record<string, unknown> }): void => {
791
+ const v = (job.variables as Record<string, unknown> | undefined) ?? {};
792
+ published = v[emitVar] ?? null;
793
+ };
794
+ await engine.registerWorker("senior:yes", async (job) => {
795
+ yesRan = true;
796
+ capture(job);
797
+ return {};
798
+ });
799
+ await engine.registerWorker("senior:no", async (job) => {
800
+ noRan = true;
801
+ capture(job);
802
+ return {};
803
+ });
804
+ const graph: DeliveryGraph = {
805
+ name: "string human guard",
806
+ nodes: [
807
+ {
808
+ id: "gate",
809
+ kind: "human",
810
+ human: { prompt: "which label?", formKey: "bespoke-label" },
811
+ emits: [{ name: opts.emitName, type: "string" }],
812
+ },
813
+ { id: "yes", kind: "agent", agent: { jobType: "senior:yes" } },
814
+ { id: "no", kind: "agent", agent: { jobType: "senior:no" } },
815
+ ],
816
+ edges: [
817
+ { from: "gate", to: "yes", when: `gate.${opts.emitName}`, equals: "approved" },
818
+ { from: "gate", to: "no", default: true },
819
+ ],
820
+ };
821
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
822
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
823
+ const key = run.handle.processInstanceKey;
824
+
825
+ let state = "?";
826
+ let task = "";
827
+ for (let round = 0; round < MAX_ROUNDS; round++) {
828
+ await engine.drain();
829
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
830
+ assert(pi, `no process instance snapshot for ${key}`);
831
+ state = pi.state ?? "?";
832
+ if (state === "COMPLETED" || state === "TERMINATED") break;
833
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
834
+ // A builtin-shadow selection that publishes/incidents on the FUNCTION parks the instance ACTIVE with
835
+ // no open user task — this assert catches that regression.
836
+ assert(open.length > 0, `instance is ${state} with no open user task — the string selection incidented on a builtin-shadow operand`);
837
+ for (const t of open) {
838
+ task = t.elementId ?? "?";
839
+ await engine.completeUserTask(t.userTaskKey, opts.entry);
840
+ }
841
+ }
842
+ return { state, yesRan, noRan, task, published };
843
+ } finally {
844
+ await engine.close();
845
+ }
846
+ }
847
+
848
+ test("#863 deploy+route: a BESPOKE single-STRING human form with a FEEL-builtin name (`count`) completed BLANK publishes null and COMPLETES (text types pass selection through — builtin-shadow class sweep)", async () => {
849
+ // `string` is a pass-through type: `coerceFactValueFeel` does NOT re-guard it, so the only defence is
850
+ // `selectExpr`'s `string(<factName>) != null` guard. A blank builtin-named explicit form must fold to
851
+ // null (not select the `count` builtin FUNCTION) and route the default rather than incident.
852
+ const r = await driveStringHumanGuard({ entry: {}, emitName: "count" });
853
+ assert(r.task.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(r.task), `expected the human task, saw ${r.task}`);
854
+ assertEquals(r.state, "COMPLETED", "a blank builtin-named string entry must publish null and run to a COMPLETED instance (not incident on the selected builtin function)");
855
+ assertEquals(r.published, null, "a blank builtin-named string emit must PUBLISH null — never the selected `count` builtin function");
856
+ assert(typeof r.published !== "function", "the published emit must never be a FEEL builtin function value");
857
+ assert(!r.yesRan, "a blank string entry publishes null, so the `= \"approved\"` guard must NOT run the TRUE branch");
858
+ assert(r.noRan, "a blank string entry publishes null, so the default branch runs");
859
+ });
860
+
861
+ test("#863 deploy+route: a BESPOKE single-STRING human form still selects a real captured value (`approved`) so the guard routes TRUE (the stringability guard does not drop legit text)", async () => {
862
+ // The `string(<factName>) != null` guard must NOT reject a genuinely captured TEXT value — a real
863
+ // `approved` entry stringifies to itself, so the fact-named candidate is still selected and the `=
864
+ // "approved"` guard routes the TRUE branch.
865
+ const r = await driveStringHumanGuard({ entry: { count: "approved" }, emitName: "count" });
866
+ assertEquals(r.state, "COMPLETED", "the graph must run to a COMPLETED instance");
867
+ assertEquals(r.published, "approved", "the guard must still PUBLISH the real captured `approved` text — `string(approved) != null` holds, so it is not dropped");
868
+ assert(r.yesRan, "a real captured `approved` entry must still be selected so the `= \"approved\"` guard routes the TRUE branch");
869
+ assert(!r.noRan, "the default branch must NOT run when the string selects `approved`");
870
+ });
871
+
872
+ test("#863 deploy+route: a BESPOKE single-ARTIFACT human form with a FEEL-builtin name (`count`) completed BLANK publishes null and COMPLETES (artifact source is never coerced — builtin-shadow class sweep)", async () => {
873
+ // The artifact source (`selectExpr(\"resolvedArtifact\")`) is passed through with NO coercion, so a blank
874
+ // builtin-named explicit form would publish the `count` builtin FUNCTION as `humanEmitArtifact` absent
875
+ // the `string(<factName>) != null` guard. With the guard the fact-named candidate folds to null and the
876
+ // instance completes instead of parking on an io-mapping incident.
877
+ const engine = await createWasmEngineClient();
878
+ try {
879
+ let publishedArtifact: unknown = "<<never published>>";
880
+ await engine.registerWorker("senior:sink", async (job) => {
881
+ const v = (job.variables as Record<string, unknown> | undefined) ?? {};
882
+ // Read the published `<el>_<fact>` artifact emit (`n0_count`), not the node-local `humanEmitArtifact`
883
+ // scratch — the scratch is localised to the human subProcess (#863, thread r4199949849) and never
884
+ // reaches a downstream worker; the flat emit is the observable published-value contract.
885
+ publishedArtifact = v.n0_count ?? null;
886
+ return {};
887
+ });
888
+ const graph: DeliveryGraph = {
889
+ name: "artifact human guard",
890
+ nodes: [
891
+ {
892
+ id: "gate",
893
+ kind: "human",
894
+ human: { prompt: "attach the artifact", formKey: "bespoke-artifact" },
895
+ emits: [{ name: "count", type: "artifact" }],
896
+ },
897
+ { id: "sink", kind: "agent", agent: { jobType: "senior:sink" } },
898
+ ],
899
+ edges: [{ from: "gate", to: "sink" }],
900
+ };
901
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
902
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
903
+ const key = run.handle.processInstanceKey;
904
+ let state = "?";
905
+ let task = "";
906
+ for (let round = 0; round < MAX_ROUNDS; round++) {
907
+ await engine.drain();
908
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
909
+ assert(pi, `no process instance snapshot for ${key}`);
910
+ state = pi.state ?? "?";
911
+ if (state === "COMPLETED" || state === "TERMINATED") break;
912
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
913
+ assert(open.length > 0, `instance is ${state} with no open user task — the artifact selection incidented on a builtin-shadow operand`);
914
+ for (const t of open) {
915
+ task = t.elementId ?? "?";
916
+ await engine.completeUserTask(t.userTaskKey, {});
917
+ }
918
+ }
919
+ assert(task.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(task), `expected the human task, saw ${task}`);
920
+ assertEquals(state, "COMPLETED", "a blank builtin-named artifact entry must publish null and run to a COMPLETED instance (not incident on the selected builtin function)");
921
+ assertEquals(publishedArtifact, null, "a blank builtin-named artifact emit must PUBLISH null — never the selected `count` builtin function");
922
+ assert(typeof publishedArtifact !== "function", "the published artifact must never be a FEEL builtin function value");
923
+ } finally {
924
+ await engine.close();
925
+ }
926
+ });
927
+
928
+ // ── #863 review 5430718031 deploy+route: human single-emit VALIDATION (fail closed) end to end ────────
929
+ // The compiler tests above assert only the SHAPE of the validity gate (`matches(…)` in the generated
930
+ // FEEL); they never EVALUATE it. These drive the typed validation END TO END on the real engine: an
931
+ // INVALID entry (a malformed version, a non-`pkg@version` artifact) must publish null and route the
932
+ // DEFAULT branch (fail closed — the guarded split's `equals` never matches null), while a VALID entry
933
+ // publishes through and routes the guarded branch. This is the observable contract the "Previously
934
+ // missed" findings asked for: human-task fact/artifact values are validated before downstream
935
+ // publication, exactly like the escalation resume path.
936
+ async function driveTypedHumanGuard(opts: { emitName: string; emitType: "version" | "artifact"; entry: Record<string, string> }): Promise<{
937
+ state: string;
938
+ published: unknown;
939
+ }> {
940
+ const engine = await createWasmEngineClient();
941
+ try {
942
+ let published: unknown = "<<never published>>";
943
+ await engine.registerWorker("senior:sink", async (job) => {
944
+ const v = (job.variables as Record<string, unknown> | undefined) ?? {};
945
+ // Read the published `<el>_<fact>` emit (`n0_<emitName>`), not the node-local
946
+ // `humanEmitValue`/`humanEmitArtifact` scratch — the scratch is localised to the human subProcess
947
+ // (#863, thread r4199949849) and never reaches a downstream worker; the flat emit is the observable
948
+ // published-value contract this validation gate protects.
949
+ published = v[`n0_${opts.emitName}`] ?? null;
950
+ return {};
951
+ });
952
+ const graph: DeliveryGraph = {
953
+ name: "typed human guard",
954
+ nodes: [
955
+ {
956
+ id: "gate",
957
+ kind: "human",
958
+ human: { prompt: "enter", formKey: "bespoke-typed" },
959
+ emits: [{ name: opts.emitName, type: opts.emitType }],
960
+ },
961
+ { id: "sink", kind: "agent", agent: { jobType: "senior:sink" } },
962
+ ],
963
+ edges: [{ from: "gate", to: "sink" }],
964
+ };
965
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
966
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
967
+ const key = run.handle.processInstanceKey;
968
+ let state = "?";
969
+ for (let round = 0; round < MAX_ROUNDS; round++) {
970
+ await engine.drain();
971
+ const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
972
+ assert(pi, `no process instance snapshot for ${key}`);
973
+ state = pi.state ?? "?";
974
+ if (state === "COMPLETED" || state === "TERMINATED") break;
975
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
976
+ assert(open.length > 0, `instance is ${state} with no open user task — the typed-validation guard incidented`);
977
+ for (const t of open) {
978
+ await engine.completeUserTask(t.userTaskKey, opts.entry);
979
+ }
980
+ }
981
+ return { state, published };
982
+ } finally {
983
+ await engine.close();
984
+ }
985
+ }
986
+
987
+ test("#863 review 5430718031 deploy+route: a single-VERSION human emit rejects a malformed version (publishes null) and accepts a valid one", async () => {
988
+ // Invalid: `not-a-version` fails the `^v?\d[\w.+-]*$` grammar → publishes null (fail closed), COMPLETES.
989
+ const bad = await driveTypedHumanGuard({ emitName: "ver", emitType: "version", entry: { ver: "not-a-version" } });
990
+ assertEquals(bad.state, "COMPLETED", "an invalid version entry must still COMPLETE (fail closed to null, not incident)");
991
+ assertEquals(bad.published, null, "an invalid version must publish null (validated fail-closed), not the malformed text");
992
+
993
+ // Valid: `1.2.3` passes the grammar → publishes through verbatim.
994
+ const good = await driveTypedHumanGuard({ emitName: "ver", emitType: "version", entry: { ver: "1.2.3" } });
995
+ assertEquals(good.state, "COMPLETED", "a valid version entry must COMPLETE");
996
+ assertEquals(good.published, "1.2.3", "a valid version must publish through verbatim");
997
+ });
998
+
999
+ test("#863 review 5430718031 deploy+route: a single-ARTIFACT human emit rejects a non-pkg@version handle (publishes null) and accepts a valid one", async () => {
1000
+ // Invalid: `not-an-artifact` fails the `^@?[^@\s]+@v?\d[\w.+-]*$` grammar → publishes null (fail closed).
1001
+ const bad = await driveTypedHumanGuard({ emitName: "release", emitType: "artifact", entry: { release: "not-an-artifact" } });
1002
+ assertEquals(bad.state, "COMPLETED", "an invalid artifact entry must still COMPLETE (fail closed to null, not incident)");
1003
+ assertEquals(bad.published, null, "an invalid artifact handle must publish null (validated fail-closed), not the malformed text");
1004
+
1005
+ // Valid (scoped pkg@version): `@nanobpm/demo@1.0.0` passes the grammar → publishes through verbatim.
1006
+ const good = await driveTypedHumanGuard({ emitName: "release", emitType: "artifact", entry: { release: "@nanobpm/demo@1.0.0" } });
1007
+ assertEquals(good.state, "COMPLETED", "a valid artifact entry must COMPLETE");
1008
+ assertEquals(good.published, "@nanobpm/demo@1.0.0", "a valid (scoped) artifact handle must publish through verbatim");
1009
+ });
@@ -27,7 +27,7 @@ import type { DataLayer, EngineClient, ProcessInstanceState } from "@nanobpm/urb
27
27
  import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
28
28
  import { isUniqueConstraintFence } from "./dbFence.ts";
29
29
  import { redactFreeText } from "./deliveryGraphCompiler.ts";
30
- import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
30
+ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement, stripDeliveryEscalationTwinSuffix } from "./deliveryHuman.ts";
31
31
 
32
32
  const now = () => new Date().toISOString();
33
33
 
@@ -469,7 +469,7 @@ export function deriveDeliveryPhase(
469
469
  // one-liner). Use the SAME exact-then-`__esc`-stripped lookup as `deliveryHumanContext*` so a
470
470
  // parked bounded-timeout twin (`…__esc`, whose exact id is never stamped) resolves to its base
471
471
  // node's label instead of showing the raw element id. A missing label falls back to the raw id.
472
- const stored = humanLabels[parkedOn] ?? humanLabels[parkedOn.replace(/__esc$/, "")];
472
+ const stored = humanLabels[parkedOn] ?? humanLabels[stripDeliveryEscalationTwinSuffix(parkedOn)];
473
473
  const label = firstLine(stored) || parkedOn;
474
474
  return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
475
475
  }
@@ -8,6 +8,8 @@ import { readFileSync } from "node:fs";
8
8
  import { assert, assertEquals, assertStringIncludes } from "#test-assert";
9
9
  import type { DeliveryFact } from "../nano-generated/api-io.d.ts";
10
10
  import {
11
+ decodeLivePrompt,
12
+ needsLiveDeliveryPrompt,
11
13
  bindHumanEmits,
12
14
  DELIVERY_HUMAN_ELEMENT,
13
15
  deliveryHumanContextQuestion,
@@ -451,13 +453,25 @@ test("deliveryHumanContextQuestion: a human node whose id itself ends in __esc r
451
453
  const genericForm = readFileSync("resources/forms/delivery-human-generic.form", "utf8");
452
454
 
453
455
  test("form-structure guard: delivery-human-generic.form uses node-neutral wording", () => {
454
- // This shared form is also attached to the `__esc`/`__contract` escalation tasks that bounded
455
- // agent/wait/connector nodes create (`app/deliveryGraphCompiler.ts`), not only scheduled `human`
456
- // nodes. Copy that calls the task a "scheduled human step" is inaccurate for the escalation family
457
- // and can obscure that the task is an escalation, so the static text must stay node-neutral.
456
+ // This shared form serves scheduled `human` nodes (the standalone `delivery-human.bpmn` and the
457
+ // compiler's `human` body). The bounded service-node escalation tasks (`__esc`/`__contract`) moved to
458
+ // their own `delivery-escalation.form` when the retry-node Resolution select landed — the select has
459
+ // no meaning on a plain human step, so it must not leak back onto this shared form.
458
460
  assert(
459
461
  !/scheduled human step/i.test(genericForm),
460
- "the shared generic form must use node-neutral wording (it also serves escalation tasks)",
462
+ "the shared generic form must use node-neutral wording",
463
+ );
464
+ });
465
+
466
+ test("form-structure guard: delivery-human-generic.form carries NO retry `decision` select (that lives on delivery-escalation.form)", () => {
467
+ // Regression guard (PR #863 adversarial review): the retry-node Resolution select was first added to
468
+ // THIS shared form, where it also rendered on plain scheduled human steps and wait-gate escalations —
469
+ // neither has retry semantics nor reads `decision`, so an operator could pick "Retry this step" and
470
+ // have it silently ignored. The select lives only on the service-escalation form now.
471
+ const parsed = JSON.parse(genericForm) as { components: { key?: string }[] };
472
+ assert(
473
+ !parsed.components.some((c) => c.key === "decision"),
474
+ "the retry `decision` select belongs on delivery-escalation.form, not the shared human form",
461
475
  );
462
476
  });
463
477
 
@@ -504,3 +518,43 @@ test("form-structure guard: delivery-human-generic.form has no task-variable-dep
504
518
  "the never-seeded readonly `prompt` control must not reappear on the Tasks surface",
505
519
  );
506
520
  });
521
+
522
+ // ── 171774: agent/connector escalation twins surface their LIVE compiler prompt ─────────────────────
523
+
524
+ test("needsLiveDeliveryPrompt: an unlabeled __esc/__contract twin reads its live prompt; human nodes keep their label", () => {
525
+ assert(needsLiveDeliveryPrompt({}, "delivery-human-task__n1__contract"), "agent contract twin");
526
+ assert(needsLiveDeliveryPrompt(undefined, "delivery-human-task__n12__esc"), "agent timeout twin");
527
+ assert(!needsLiveDeliveryPrompt({ "delivery-human-task__n3": "Approve" }, "delivery-human-task__n3__esc"), "human node __esc twin keeps its label");
528
+ assert(!needsLiveDeliveryPrompt({}, "delivery-human-task__n3"), "a human node itself is never a twin");
529
+ });
530
+
531
+ test("decodeLivePrompt: decodes the engine's JSON-encoded string; null for missing, non-string, or truncated", () => {
532
+ assertEquals(decodeLivePrompt({ value: JSON.stringify("Node i10 blocked. Agent report: no repo."), isTruncated: false }), "Node i10 blocked. Agent report: no repo.");
533
+ assertEquals(decodeLivePrompt(undefined), null);
534
+ assertEquals(decodeLivePrompt({ value: "42" }), null);
535
+ assertEquals(decodeLivePrompt({ value: JSON.stringify("partial…"), isTruncated: true }), null, "never show a clipped prompt as whole");
536
+ assertEquals(decodeLivePrompt({ value: "{not json" }), null);
537
+ });
538
+
539
+ test("decodeLivePrompt: redacts an embedded credential before the prompt is persisted as user_tasks.question (issue #863 review)", () => {
540
+ // The compiler embeds agent-controlled summary/question/error/transcriptUrl into the escalation
541
+ // prompt; an agent report can carry a token-bearing URL or credential. The static human_labels path
542
+ // is redacted via redactFreeText (issue #778) — the live-prompt path must redact the same way.
543
+ const withUserinfo = decodeLivePrompt({ value: JSON.stringify("Agent report: failed pushing to https://user:secret-token@github.com/org/repo.git — see log.") });
544
+ assert(withUserinfo !== null);
545
+ assert(!withUserinfo.includes("secret-token"), `credential must not survive: ${withUserinfo}`);
546
+ assertStringIncludes(withUserinfo, "//***@");
547
+
548
+ const withQueryToken = decodeLivePrompt({ value: JSON.stringify("Transcript: https://logs.example.com/t?sig=secret123") });
549
+ assert(withQueryToken !== null);
550
+ assert(!withQueryToken.includes("secret123"), `query credential must not survive: ${withQueryToken}`);
551
+
552
+ // A credential-free prompt is untouched (redaction is a no-op on ordinary prose).
553
+ assertEquals(decodeLivePrompt({ value: JSON.stringify("Node i10 blocked. Agent report: no repo.") }), "Node i10 blocked. Agent report: no repo.");
554
+ });
555
+
556
+ test("deliveryHumanContextQuestion: a live prompt replaces the static fallback but never a human label", () => {
557
+ assertEquals(deliveryHumanContextQuestion({}, "delivery-human-task__n1__contract", "Node a blocked."), "Node a blocked.");
558
+ assertEquals(deliveryHumanContextQuestion({ "delivery-human-task__n3": "Approve" }, "delivery-human-task__n3__esc", "nag"), "Approve");
559
+ assertStringIncludes(deliveryHumanContextQuestion({}, "delivery-human-task__n1__contract", null), "waiting to be completed");
560
+ });