@nanobpm/nano-workforce 0.200.9 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +188 -0
  3. package/app/agentCompletion.test.ts +52 -0
  4. package/app/agentCompletion.ts +20 -5
  5. package/app/contracts.ts +9 -0
  6. package/app/deliveryGraph.test.ts +261 -0
  7. package/app/deliveryGraph.ts +334 -1
  8. package/app/deliveryGraphCompiler.test.ts +1698 -39
  9. package/app/deliveryGraphCompiler.ts +1047 -149
  10. package/app/deliveryGraphDeploy.test.ts +472 -3
  11. package/app/deliveryGraphRun.ts +2 -2
  12. package/app/deliveryHuman.test.ts +59 -5
  13. package/app/deliveryHuman.ts +115 -3
  14. package/app/deliveryRunner.test.ts +3 -3
  15. package/app/deliveryRunner.ts +14 -5
  16. package/app/pullRequestReadModel.test.ts +1 -1
  17. package/app/redactText.ts +88 -0
  18. package/app/roundChurn.test.ts +388 -0
  19. package/app/roundChurn.ts +265 -0
  20. package/app/roundProgress.test.ts +224 -1
  21. package/app/service.test.ts +7 -0
  22. package/app/service.ts +28 -2
  23. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  24. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  25. package/docs/agent-guide.md +10 -7
  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 +580 -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/workers/progress-check/worker.ts +98 -1
  33. package/workers/readiness-probe/worker.test.ts +72 -1
  34. package/workers/readiness-probe/worker.ts +39 -0
@@ -480,6 +480,188 @@ test("invalid-fact-name: an emitted fact name over the openapi 128-char cap is r
480
480
  assertEquals(err.path, "nodes[0].emits[0].name");
481
481
  });
482
482
 
483
+ test("invalid-fact-name: an emit named after an escalation control (`decision`/`value`/`escalationNote`) is rejected — a Continue would overwrite the fact (r4181027093)", () => {
484
+ // Regression guard (PR #863 Copilot High, thread r4181027093): an agent/connector emit's source is the
485
+ // fact's own name, declared node-local in the SAME scope as the escalation form's `decision`/`value`/
486
+ // `escalationNote`. An agent emitting `decision` that reaches a contract escalation has its fact overwritten by
487
+ // the form completion (`decision="continue"`), so the subProcess publishes `<el>_decision="continue"`
488
+ // instead of the agent's routing value. The validator must reject the whole class fail-closed.
489
+ // (`note` is NOT an escalation control — it is a plan.md worker RESULT field; the retry-guidance
490
+ // control is the worker-unwritable `escalationNote`, PR #863 "Previously missed" deliveryGraphCompiler.ts:2156.)
491
+ for (const name of ["decision", "value", "escalationNote"]) {
492
+ const errors = validateDeliveryGraph({
493
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name, type: "string" }] }],
494
+ });
495
+ const err = hasCode(errors, "invalid-fact-name");
496
+ assertEquals(err.path, "nodes[0].emits[0].name", `emit '${name}' is rejected`);
497
+ assert(err.message.includes("reserved"), `emit '${name}' names the reserved collision, got: ${err.message}`);
498
+ }
499
+ });
500
+
501
+ test("invalid-fact-name: an emit named after a connector config variable (`target`/`payload`/`dedupeKey`/`nodeTimeout`) is rejected — a Retry would null the config (r4181027147)", () => {
502
+ // Regression guard (PR #863 Copilot High, thread r4181027147): the retry reset clears every declared
503
+ // emit source var; a connector emitting `target`/`payload`/`dedupeKey`/`nodeTimeout` (also subProcess
504
+ // config vars) has them NULLED without re-entering the input mappings, so the retried connector
505
+ // activates unconfigured. Reject the collision at authoring time.
506
+ for (const name of ["target", "payload", "dedupeKey", "nodeTimeout"]) {
507
+ const errors = validateDeliveryGraph({
508
+ nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name, type: "string" }] }],
509
+ });
510
+ const err = hasCode(errors, "invalid-fact-name");
511
+ assertEquals(err.path, "nodes[0].emits[0].name", `connector emit '${name}' is rejected`);
512
+ }
513
+ });
514
+
515
+ test("invalid-fact-name: an emit named after shared late-binding/preflight scaffolding is rejected (boundFacts/nodeInputs/nodeInputsPresent)", () => {
516
+ // The reserved set also covers the shared subProcess scaffolding that lives in the emit's scope.
517
+ for (const name of ["boundFacts", "nodeInputs", "nodeInputsPresent"]) {
518
+ const errors = validateDeliveryGraph({
519
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name, type: "string" }] }],
520
+ });
521
+ const err = hasCode(errors, "invalid-fact-name");
522
+ assertEquals(err.path, "nodes[0].emits[0].name", `emit '${name}' is rejected`);
523
+ }
524
+ });
525
+
526
+ test("a node-local RESULT field is NOT a reserved emit name — an agent may emit `pr` (the canonical converge shape)", () => {
527
+ // Guard against over-reserving (PR #863 design): the result sets (AGENT_RESULT_LOCAL_VARS /
528
+ // CONNECTOR_RESULT_LOCAL_VARS) are NOT reserved — an agent emitting `pr` writes the same node-local
529
+ // value the result field holds, and the retry reset correctly clears both. Reserving them would
530
+ // forbid the flagship `agent → connector[converge] → wait[pr]` pattern.
531
+ const errors = validateDeliveryGraph({
532
+ nodes: [{ id: "open", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "pr", type: "pr" }] }],
533
+ });
534
+ assertEquals(errors.length, 0, `an agent emitting 'pr' validates, got: ${JSON.stringify(errors)}`);
535
+ });
536
+
537
+ test("invalid-fact-name: an AGENT emit named `status` is reserved — the producer gate reads that node-local field as its completion status", () => {
538
+ // Regression guard (PR #863 Copilot Medium "Previously missed", deliveryGraph.ts:418): the result-set
539
+ // exemption was too broad for an agent emit named `status`. The producer status gate
540
+ // (`agentContractProceedCondition`) interprets that same node-local field as the completion status and
541
+ // accepts only `done`/`opened`/`skipped`, while the emit contract may require a different routing
542
+ // value — `status="approved"` always escalates, and `status="done"` can never take an `approved`
543
+ // branch. Unlike an aligned result fact such as `pr` (whose emit value and result field agree),
544
+ // `status` is CONSTRAINED by the gate, so the two uses conflict. Reserve the agent completion-control
545
+ // `status` while continuing to allow aligned result facts such as `pr`.
546
+ const errors = validateDeliveryGraph({
547
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "status", type: "string" }] }],
548
+ });
549
+ const err = hasCode(errors, "invalid-fact-name");
550
+ assertEquals(err.path, "nodes[0].emits[0].name", "an agent emitting 'status' is rejected");
551
+ assert(err.message.includes("reserved"), `emit 'status' names the reserved collision, got: ${err.message}`);
552
+ // A CONNECTOR has no producer status gate, so its `status` emit collides with nothing — allowed.
553
+ const connector = validateDeliveryGraph({
554
+ nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name: "status", type: "string" }] }],
555
+ });
556
+ assertEquals(connector.length, 0, `a connector emitting 'status' validates (no producer gate), got: ${JSON.stringify(connector)}`);
557
+ });
558
+
559
+ test("invalid-fact-name is KIND-AWARE: an agent may emit a connector-only config name (`target`), and vice versa (r4181322008)", () => {
560
+ // Regression guard (PR #863 Copilot Medium, thread r4181322008): the reserved set reserves only the
561
+ // node's OWN kind's config. An agent seeds no `target`/`payload`/`dedupeKey` (connector-only), so an
562
+ // agent emitting `target` collides with nothing; symmetrically a connector seeds no `jobType`/
563
+ // `appendPrompt` (agent-only). Only the shared `nodeTimeout` is reserved for both.
564
+ const agentTarget = validateDeliveryGraph({
565
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "target", type: "string" }] }],
566
+ });
567
+ assertEquals(agentTarget.length, 0, `an agent emitting 'target' validates, got: ${JSON.stringify(agentTarget)}`);
568
+ const connectorJobType = validateDeliveryGraph({
569
+ nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name: "jobType", type: "string" }] }],
570
+ });
571
+ assertEquals(connectorJobType.length, 0, `a connector emitting 'jobType' validates, got: ${JSON.stringify(connectorJobType)}`);
572
+ // …but each kind's OWN config is still reserved (the collision is real there).
573
+ const agentAppend = validateDeliveryGraph({
574
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "appendPrompt", type: "string" }] }],
575
+ });
576
+ assertEquals(hasCode(agentAppend, "invalid-fact-name").path, "nodes[0].emits[0].name", "an agent emitting its OWN config 'appendPrompt' is rejected");
577
+ const connectorTarget = validateDeliveryGraph({
578
+ nodes: [{ id: "c", kind: "connector", connector: { target: "slack:#a" }, emits: [{ name: "target", type: "string" }] }],
579
+ });
580
+ assertEquals(hasCode(connectorTarget, "invalid-fact-name").path, "nodes[0].emits[0].name", "a connector emitting its OWN config 'target' is rejected");
581
+ });
582
+
583
+ test("invalid-fact-name does NOT apply a config restriction to wait/human emits, whose source is a fixed intermediate (r4181322008)", () => {
584
+ // A wait/human emit's source is NOT the fact's own name (factSourceVar maps it to `detail`/
585
+ // `humanEmitValue`/…), so its config vars can't collide with an emit source — a wait emitting
586
+ // `target`/`probe` or a human emitting `prompt`/`emitMode` is fine. Only the escalation
587
+ // controls + shared scaffolding stay reserved for these kinds.
588
+ const waitTarget = validateDeliveryGraph({
589
+ nodes: [{ id: "w", kind: "wait", wait: { kind: "github-check", target: "o/r@main" }, emits: [{ name: "target", type: "string" }, { name: "probe", type: "string" }] }],
590
+ });
591
+ assertEquals(waitTarget.length, 0, `a wait emitting 'target'/'probe' validates, got: ${JSON.stringify(waitTarget)}`);
592
+ // `prompt`/`emitMode` are not reserved for a human node. Assert each as its OWN single-emit human node
593
+ // — a two-emit human node with no form is separately rejected (`human-unroutable-emits`, thread
594
+ // deliveryGraphCompiler.ts:1859), which would otherwise mask the reserved-name intent under test here.
595
+ const humanPrompt = validateDeliveryGraph({
596
+ nodes: [
597
+ { id: "h1", kind: "human", human: { prompt: "do it" }, emits: [{ name: "prompt", type: "string" }] },
598
+ { id: "h2", kind: "human", human: { prompt: "do it" }, emits: [{ name: "emitMode", type: "string" }] },
599
+ ],
600
+ });
601
+ assertEquals(humanPrompt.length, 0, `a human emitting 'prompt'/'emitMode' validates, got: ${JSON.stringify(humanPrompt)}`);
602
+ // …but the escalation controls + scaffolding are STILL reserved for wait/human.
603
+ const humanDecision = validateDeliveryGraph({
604
+ nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "decision", type: "string" }] }],
605
+ });
606
+ assertEquals(hasCode(humanDecision, "invalid-fact-name").path, "nodes[0].emits[0].name", "a human emitting 'decision' (escalation control) is still rejected");
607
+ const waitScaffold = validateDeliveryGraph({
608
+ nodes: [{ id: "w", kind: "wait", wait: { kind: "github-check", target: "o/r@main" }, emits: [{ name: "nodeInputs", type: "string" }] }],
609
+ });
610
+ assertEquals(hasCode(waitScaffold, "invalid-fact-name").path, "nodes[0].emits[0].name", "a wait emitting 'nodeInputs' (scaffolding) is still rejected");
611
+ });
612
+
613
+ test("human-unroutable-emits: a human node emitting ≥2 facts with no form is rejected — no static form captures them (thread deliveryGraphCompiler.ts:1859)", () => {
614
+ // Class guard (PR #863 Copilot High): a human form captures ONE value and a human node has no Retry
615
+ // path, so a node emitting two or more facts with no explicit `human.formKey` resolves to the
616
+ // agent-router — no static form holds them — and completing its form would publish null for the
617
+ // uncapturable emits. Reject the class at authoring time rather than silently starve consumers.
618
+ const twoScalars = validateDeliveryGraph({
619
+ nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "a", type: "string" }, { name: "b", type: "string" }] }],
620
+ });
621
+ assertEquals(hasCode(twoScalars, "human-unroutable-emits").path, "nodes[0].emits", "two non-artifact emits with no form are rejected");
622
+ // The carve-out the finding cites: one scalar + one artifact — the generic form has no resolvedArtifact
623
+ // control and the publish form no value control, so NO static form captures both. Also rejected.
624
+ const scalarPlusArtifact = validateDeliveryGraph({
625
+ nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }, { name: "report", type: "artifact" }] }],
626
+ });
627
+ assertEquals(hasCode(scalarPlusArtifact, "human-unroutable-emits").path, "nodes[0].emits", "one scalar + one artifact with no form is rejected");
628
+ // An explicit `human.formKey` is NOT a workaround: the compiled human task's ioMapping reads only the
629
+ // fixed `value`/`resolvedArtifact`/`note` controls (humanBodyLines), never a bespoke form's per-fact
630
+ // fields, so a ≥2-emit explicit-form node would STILL publish null for every non-artifact emit.
631
+ // Reject it too (PR #863 Copilot High, thread r4184443511) — the only correct shape is single-emit
632
+ // human nodes.
633
+ const explicitForm = validateDeliveryGraph({
634
+ nodes: [{ id: "h", kind: "human", human: { prompt: "do it", formKey: "bespoke-multi" }, emits: [{ name: "a", type: "string" }, { name: "b", type: "string" }] }],
635
+ });
636
+ assertEquals(hasCode(explicitForm, "human-unroutable-emits").path, "nodes[0].emits", "an explicit human.formKey does NOT make a ≥2-emit node capturable — the ioMapping cannot read its bespoke fields");
637
+ // Single-emit human nodes are capturable → NOT rejected (the guard must not over-reject).
638
+ const singleScalar = validateDeliveryGraph({
639
+ nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "verdict", type: "string" }] }],
640
+ });
641
+ assertEquals(singleScalar.filter((e) => e.code === "human-unroutable-emits").length, 0, "a single non-artifact emit is capturable (generic form)");
642
+ const singleArtifact = validateDeliveryGraph({
643
+ nodes: [{ id: "h", kind: "human", human: { prompt: "do it" }, emits: [{ name: "art", type: "artifact" }] }],
644
+ });
645
+ assertEquals(singleArtifact.filter((e) => e.code === "human-unroutable-emits").length, 0, "a single artifact emit is capturable (publish form)");
646
+ });
647
+
648
+ test("a non-reserved emit name still validates (the reserved guard does not over-reject)", () => {
649
+ const errors = validateDeliveryGraph({
650
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "verdict", type: "string" }, { name: "targetRef", type: "string" }] }],
651
+ });
652
+ assertEquals(errors.length, 0, `a non-reserved emit name passes, got: ${JSON.stringify(errors)}`);
653
+ });
654
+
655
+ test("fact-name `resumeValid` is NOT reserved — a user emit may use it (PR #876 review: the resume-validation gate's flag is an internal compiler-generated var, not a user fact name, so reserving `resumeValid` would needlessly break recompilation of durable rows that already carry a fact of that name)", () => {
656
+ const errors = validateDeliveryGraph({
657
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "resumeValid", type: "boolean" }] }],
658
+ });
659
+ assert(
660
+ !errors.some((e) => e.path === "nodes[0].emits[0].name"),
661
+ `expected no fact-name error for \`resumeValid\`, got ${JSON.stringify(errors)}`,
662
+ );
663
+ });
664
+
483
665
  test("invalid-fact-type: an emitted fact with a type outside the allowlist is rejected, path-qualified", () => {
484
666
  const errors = validateDeliveryGraph({
485
667
  nodes: [
@@ -712,6 +894,85 @@ test("invalid-job-type: an `agent.jobType` carrying attribute whitespace (LF) is
712
894
  hasCode(errors, "invalid-job-type");
713
895
  });
714
896
 
897
+ test("invalid-form-key: a `human.formKey` carrying an XML-1.0-invalid character is rejected, not silently rewritten into a different executable form id (PR #863 review)", () => {
898
+ // `human.formKey` is emitted VERBATIM as the executable `<zeebe:formDefinition formId=…>` (it resolves
899
+ // a deployed `.form` by value). An XML-1.0-forbidden control char would be STRIPPED by the attribute
900
+ // sanitiser — resolving a DIFFERENT (wrong) form — so it must be rejected, exactly like `agent.jobType`.
901
+ const errors = validateDeliveryGraph({
902
+ nodes: [{ id: "step", kind: "human", human: { formKey: "my-form\u0001x" } }],
903
+ edges: [],
904
+ });
905
+ hasCode(errors, "invalid-form-key");
906
+ });
907
+
908
+ test("invalid-form-key: a `human.formKey` carrying attribute whitespace (LF) is rejected — XML attribute-value normalization would fold it to a space, resolving a different form (PR #863 review)", () => {
909
+ const errors = validateDeliveryGraph({
910
+ nodes: [{ id: "step", kind: "human", human: { formKey: "my\nform" } }],
911
+ edges: [],
912
+ });
913
+ hasCode(errors, "invalid-form-key");
914
+ });
915
+
916
+ test("invalid-form-key: a PRESENT-but-non-string `human.formKey` is rejected, not silently dropped to a generated form (PR #863 review — fail-open class)", () => {
917
+ // The class: a non-string optional config value bypasses the `typeof === "string"`-gated validators
918
+ // and is silently accepted, so the compiler falls back to a generated form instead of the author's
919
+ // declared executable `<zeebe:formDefinition formId=…>` key. Every non-string shape must fail closed.
920
+ for (const bad of [42, {}, [], true]) {
921
+ const errors = validateDeliveryGraph({
922
+ nodes: [{ id: "step", kind: "human", human: { formKey: bad } }],
923
+ edges: [],
924
+ });
925
+ hasCode(errors, "invalid-form-key");
926
+ }
927
+ });
928
+
929
+ test("credential-in-form-key: a URL-/credential-shaped `human.formKey` is REJECTED — it is baked verbatim into the executable `<zeebe:formDefinition formId=…>`, so an embedded credential would leak into the compiled BPMN the preview door returns (PR #863 review)", () => {
930
+ // whole-value URL carrying a `?token=` query (no userinfo needed to be a leak risk)
931
+ const whole = validateDeliveryGraph({
932
+ nodes: [{ id: "step", kind: "human", human: { formKey: "https://evil.example/f.form?token=secret" } }],
933
+ edges: [],
934
+ });
935
+ const err = hasCode(whole, "credential-in-form-key");
936
+ assert(!err.message.includes("token=secret"), `the credential-in-form-key message must redact the query token, got: ${err.message}`);
937
+ // embedded `//user:pass@host` past an otherwise plausible token
938
+ hasCode(
939
+ validateDeliveryGraph({
940
+ nodes: [{ id: "step", kind: "human", human: { formKey: "my-form //user:pass@evil.example/f" } }],
941
+ edges: [],
942
+ }),
943
+ "credential-in-form-key",
944
+ );
945
+ // explicit-scheme embedded URL with a `?token=` query
946
+ hasCode(
947
+ validateDeliveryGraph({
948
+ nodes: [{ id: "step", kind: "human", human: { formKey: "my-form https://evil.example/f?token=secret" } }],
949
+ edges: [],
950
+ }),
951
+ "credential-in-form-key",
952
+ );
953
+ // scheme-relative `//host?token=` authority
954
+ hasCode(
955
+ validateDeliveryGraph({
956
+ nodes: [{ id: "step", kind: "human", human: { formKey: "my-form //evil.example/f?token=secret" } }],
957
+ edges: [],
958
+ }),
959
+ "credential-in-form-key",
960
+ );
961
+ });
962
+
963
+ test("human.formKey: a clean form id (and a blank/absent one) passes the formKey gates", () => {
964
+ for (const formKey of ["my-custom-form", " ", undefined]) {
965
+ assertEquals(
966
+ validateDeliveryGraph({
967
+ nodes: [{ id: "step", kind: "human", human: formKey === undefined ? {} : { formKey } }],
968
+ edges: [],
969
+ }).filter((e) => e.code === "invalid-form-key" || e.code === "credential-in-form-key"),
970
+ [],
971
+ `formKey ${JSON.stringify(formKey)} must pass the formKey gates`,
972
+ );
973
+ }
974
+ });
975
+
715
976
  test("invalid-job-type: a clean `agent.jobType` (no XML-invalid characters, no attribute whitespace) passes validation", () => {
716
977
  assertEquals(
717
978
  validateDeliveryGraph({