@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.201.1](https://github.com/nanobpm/nano-workforce/compare/v0.201.0...v0.201.1) (2026-10-07)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** actionable node escalations — node-local agent results, retry-node, inputs preflight ([#863](https://github.com/nanobpm/nano-workforce/issues/863)) ([50f7595](https://github.com/nanobpm/nano-workforce/commit/50f7595f3156b2e1e9d52e30e87d3e1c22951dc8)), closes [nano-bpm#1335](https://github.com/nanobpm/nano-bpm/issues/1335) [nano-bpm#1334](https://github.com/nanobpm/nano-bpm/issues/1334) [nano-bpm#1336](https://github.com/nanobpm/nano-bpm/issues/1336) [nanobpm/nano-workforce#866](https://github.com/nanobpm/nano-workforce/issues/866) [nano-bpm#1336](https://github.com/nanobpm/nano-bpm/issues/1336) [nano-bpm#1334](https://github.com/nanobpm/nano-bpm/issues/1334) [nano-bpm#946](https://github.com/nanobpm/nano-bpm/issues/946) [#543](https://github.com/nanobpm/nano-workforce/issues/543) [#866](https://github.com/nanobpm/nano-workforce/issues/866) [#778](https://github.com/nanobpm/nano-workforce/issues/778) [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#772](https://github.com/nanobpm/nano-workforce/issues/772) [#461](https://github.com/nanobpm/nano-workforce/issues/461) [#876](https://github.com/nanobpm/nano-workforce/issues/876)
|
|
6
|
+
|
|
1
7
|
## [0.201.0](https://github.com/nanobpm/nano-workforce/compare/v0.200.9...v0.201.0) (2026-10-06)
|
|
2
8
|
|
|
3
9
|
### Features
|
package/SPEC.md
CHANGED
|
@@ -1039,6 +1039,159 @@ decision. The target behaviour is an **autonomy ladder**:
|
|
|
1039
1039
|
A human escalation is then reserved for its one true case: **two slices that each pass
|
|
1040
1040
|
but encode incompatible decisions about a shared contract** — a genuine design call.
|
|
1041
1041
|
|
|
1042
|
+
## 13.3 Delivery-graph node escalation — Continue / Retry / reset (PR #863)
|
|
1043
|
+
|
|
1044
|
+
The delivery-graph compiler (`app/deliveryGraphCompiler.ts`, ADR 0005) compiles each
|
|
1045
|
+
`agent`/`connector` node to a `start → serviceTask → end` subProcess guarded by a
|
|
1046
|
+
**bounded `=nodeTimeout` boundary**. On timeout (or, for an `agent`, on a broken
|
|
1047
|
+
producer contract — issue #731) the stalled node parks on a **human-completable
|
|
1048
|
+
escalation user task** (`delivery-human-task__<el>__esc`, and the agent-only
|
|
1049
|
+
`__contract` twin), surfaced in the Tasks inbox and answerable by a human or an agent
|
|
1050
|
+
(ADR 0046). Every such escalation names **two exits** (the `delivery-escalation` form's
|
|
1051
|
+
`decision` select):
|
|
1052
|
+
|
|
1053
|
+
- **Continue** (`decision="continue"`, the default) completes the node as resolved. If
|
|
1054
|
+
the node declares **exactly one** emit **and that emit is a downstream-required fact**,
|
|
1055
|
+
the form presents a typed-value field and maps
|
|
1056
|
+
the operator-supplied `value` onto the node's emit-source var (`factSourceVar`), so the
|
|
1057
|
+
subProcess output publishes the same `<el>_<fact>` a normal completion would —
|
|
1058
|
+
letting work finished out of band (a draft PR the stalled agent already opened) be
|
|
1059
|
+
handed onward instead of threading a null downstream. This applies to **both** `agent`
|
|
1060
|
+
and `connector` emits: a connector has no producer-contract gate, so its resume keys
|
|
1061
|
+
off the node's **own** declared `emits` (emit source = the fact's own name), never the
|
|
1062
|
+
agent-only gate metadata. The single-emit boundary is a hard cardinality rule: the
|
|
1063
|
+
form captures ONE value, so a node declaring **zero** emits has nothing to resume (the
|
|
1064
|
+
field stays blank) and a node declaring **two or more** emits is **not** value-resumable
|
|
1065
|
+
— one value cannot satisfy multiple distinct typed facts without corrupting them.
|
|
1066
|
+
**The value is also inert for a node with no *required* (downstream-consumed) resume
|
|
1067
|
+
target** — the resume keys off the **required** emit set, not the raw declared set, so a
|
|
1068
|
+
node whose facts nothing downstream reads has no required resume target and its `value` is
|
|
1069
|
+
discarded (there is nothing to publish it to). This covers a node that declares **zero**
|
|
1070
|
+
emits, exactly **one** routing-only/unconsumed emit, or **several** routing-only/unconsumed
|
|
1071
|
+
emits alike: because the required resume set (`resumeEmits`) is empty, **no post-escalation
|
|
1072
|
+
validation gate is compiled at all**, so **Continue simply proceeds past the node to its
|
|
1073
|
+
default (fallback) branch** — there is nothing to re-park (`serviceBodyLines` /
|
|
1074
|
+
`escalationTaskLines` add the `validTarget` gate only when `resumeEmits` is non-empty). The
|
|
1075
|
+
fail-closed validation gate exists **only** for a node that still owes a **required** emit:
|
|
1076
|
+
there, on Continue it **fails closed and loops
|
|
1077
|
+
back to the escalation** (it does NOT write nulls and does NOT advance to a fallback branch)
|
|
1078
|
+
— **Retry this step** is the only way to actually produce the facts, except that a node
|
|
1079
|
+
declaring exactly **one** emit — that single declared emit being its only required resume
|
|
1080
|
+
target — may instead satisfy it by entering the coerced `value` (the value field stays inert
|
|
1081
|
+
for a node declaring two or more emits even when only one is required, matching
|
|
1082
|
+
`declaredEmits.length === 1 && emits.length === 1` in the compiler).
|
|
1083
|
+
(A **wait** gate owing **two or more**
|
|
1084
|
+
emits is a special case: it has no Retry exit and its single value field can resume
|
|
1085
|
+
none of them, so its escalation grows **no** validation gate at all and Continue simply
|
|
1086
|
+
acknowledges and proceeds to the node end — see issue #863 — rather than looping forever.)
|
|
1087
|
+
Because the form
|
|
1088
|
+
captures `value` as textfield **text**, the single-emit resume **coerces/validates it to
|
|
1089
|
+
the emit's declared type** before publishing (`escalationResumeValueFeel`): a `boolean`
|
|
1090
|
+
emit accepts `"true"`/`"false"` **case-insensitively and surrounding-whitespace-trimmed**
|
|
1091
|
+
(the validity gate and the coercion share one `lower case(trim(string(value)))`
|
|
1092
|
+
normalization, so a value one accepts the other can never convert differently) and
|
|
1093
|
+
publishes a real boolean, a `number` emit parses via `number(trim(string(value)))`, and
|
|
1094
|
+
any other (text-valued) type passes through. An **invalid** entry
|
|
1095
|
+
publishes `null` — the defined failure path — so a required-emit producer gate escalates
|
|
1096
|
+
and a guarded split takes its default rather than routing on a mistyped value (a raw
|
|
1097
|
+
`"true"` string would silently skip a `= true` guard).
|
|
1098
|
+
- **Retry this step** (`decision="retry"`) re-runs the node. A none intermediate throw
|
|
1099
|
+
event (never a scriptTask) resets the node-local scratch — the decision, the captured
|
|
1100
|
+
`value`/`escalationNote`, and (for an agent) the **full declared result set**
|
|
1101
|
+
(`AGENT_RESULT_LOCAL_VARS` — the self-reported `status`/`summary`/… **and** every other
|
|
1102
|
+
node-local result field such as `transcriptUrl`, `agentCheckpoint`, and the PR aliases)
|
|
1103
|
+
— or, **for a connector**, its fixed result metadata (`CONNECTOR_RESULT_LOCAL_VARS` —
|
|
1104
|
+
`connectorOutcome`/`connectorDedupeKey`/`connectorDetail`) — plus the previous attempt's
|
|
1105
|
+
emits — and appends the operator's `escalationNote` to the agent
|
|
1106
|
+
prompt as guidance for the next attempt (re-derived from the runner-seeded
|
|
1107
|
+
`nodeInputs.<el>.appendPrompt` baseline, so consecutive retries never accumulate stale
|
|
1108
|
+
guidance). The guidance control is the escalation-specific `escalationNote`, never the
|
|
1109
|
+
plain `note`: `note` is a worker **result** field (`plan.md` returns one) that
|
|
1110
|
+
nearest-scope propagation lands in the same subProcess scope, so reading it would feed a
|
|
1111
|
+
worker-produced note back as "operator guidance" on a retry submitted without one. Clearing
|
|
1112
|
+
the whole declared set (not just the status fields) stops a retried
|
|
1113
|
+
worker that omits an optional field from republishing the previous attempt's value
|
|
1114
|
+
downstream or surfacing it in the next escalation.
|
|
1115
|
+
|
|
1116
|
+
All retry/reset targets are **node-local** (declared on the subProcess by
|
|
1117
|
+
`ioMappingLines`), so a node's result vars never leak to the root and two parallel nodes
|
|
1118
|
+
declaring the same emit never cross-publish — with a few deliberate publish-onward
|
|
1119
|
+
exceptions, all written under node-unique or intentionally-shared names rather than the
|
|
1120
|
+
raw result var. The **outward data** exceptions: each declared emit is republished to the
|
|
1121
|
+
root as `<el>_<fact>` (node-unique, so siblings never collide), and an agent's
|
|
1122
|
+
`transcriptUrl` is propagated back to the root under the shared `transcriptUrl` name so
|
|
1123
|
+
Nano Explorer can render the run→transcript link (app/deliveryGraphCompiler.ts:1872-1877).
|
|
1124
|
+
That one shared name means parallel agents can overwrite the root `transcriptUrl` —
|
|
1125
|
+
acceptable, since it is a display-only correlation link, not a result a downstream node
|
|
1126
|
+
binds. In addition, the per-node **control signals** `<el>_contractMet` (the agent
|
|
1127
|
+
contract-gate's proceed flag) and `<el>_retryRequested` (the per-escalation retry gate's
|
|
1128
|
+
route flag) are **also root-scoped** — their gateways live outside the node's subProcess
|
|
1129
|
+
and so cannot read node scope, and the `<el>_`-prefixed, node-unique names can never
|
|
1130
|
+
collide across parallel nodes. So the node-local rule governs a node's *result/emit scratch*;
|
|
1131
|
+
these node-unique control booleans are intentionally published to root so the routing
|
|
1132
|
+
gateways can read them on either engine's scope semantics. A **preflight `assert`** on the inner leaf
|
|
1133
|
+
task's input fails LOUD (raising an incident naming the missing `nodeInputs.<el>`)
|
|
1134
|
+
before any job exists when the runner-seeded config was lost — see the `KNOWN
|
|
1135
|
+
LIMITATION` in `serviceBodyLines` (fail-loud-only, nano-workforce#866). Resolving that
|
|
1136
|
+
incident re-evaluates **only the leaf's inputs** — the subProcess-level config mappings
|
|
1137
|
+
(`prompt`/`appendPrompt`/`nodeTimeout`, connector `target`/`payload`/`dedupeKey`) ran once
|
|
1138
|
+
at subProcess entry and are **not** re-mapped on a leaf resolve, so the node would
|
|
1139
|
+
activate **unconfigured**. The in-subprocess **"Retry this step"** loop does **not**
|
|
1140
|
+
help here either: it loops directly back to the inner service task (to reset the
|
|
1141
|
+
node-local scratch), bypassing the subProcess entry mappings, so it too leaves the
|
|
1142
|
+
config null. **Recovery therefore requires re-entering the sub-process from OUTSIDE —
|
|
1143
|
+
relaunch/re-enter the node so its subProcess input mappings re-evaluate against the
|
|
1144
|
+
restored `nodeInputs`** — not merely resolving the incident or using "Retry this step".
|
|
1145
|
+
The correct in-model fix is to run this check at sub-process *entry* (Camunda parity,
|
|
1146
|
+
nano-bpm#1336); until then the preflight fails loud so the node never runs blind
|
|
1147
|
+
*without* an incident, and the operator re-enters the sub-process to recover.
|
|
1148
|
+
|
|
1149
|
+
Because an `agent`/`connector` node's emit source **is the fact's own name**, declared
|
|
1150
|
+
node-local in the same subProcess scope as the escalation form controls and the node's
|
|
1151
|
+
seeded config, a fact name is not fully unrestricted: `validateDeliveryGraph` rejects an
|
|
1152
|
+
emit named after a **reserved delivery variable** (`reservedDeliveryFactNames(kind)`,
|
|
1153
|
+
app/deliveryGraph.ts) fail-closed at authoring time. The reserved set is **kind-aware**
|
|
1154
|
+
(issue #863 review): the **escalation controls** (`decision`/`value`/`escalationNote` — an escalation
|
|
1155
|
+
Continue would overwrite the fact, publishing `<el>_decision="continue"` instead of the
|
|
1156
|
+
agent's routing value) and the shared late-binding/preflight **scaffolding**
|
|
1157
|
+
(`boundFacts`/`nodeInputs`/`nodeInputsPresent`) are reserved for **every** kind, while a
|
|
1158
|
+
**config variable** is reserved only for the kind whose own config it is — and only for
|
|
1159
|
+
`agent`/`connector`, the kinds whose emit source is the fact's own name (the retry reset's
|
|
1160
|
+
clear-the-emits pass would null the node's own configuration, so the retried node activates
|
|
1161
|
+
unconfigured). A `wait`/`human` emit's source is a fixed intermediate (`detail`/
|
|
1162
|
+
`humanEmitValue`/…), never the fact's own name, so those kinds reserve only the escalation
|
|
1163
|
+
controls + scaffolding — a `wait` emitting `target` or a `human` emitting `prompt` is
|
|
1164
|
+
allowed. The node-local **result** fields
|
|
1165
|
+
(`AGENT_RESULT_LOCAL_VARS`/`CONNECTOR_RESULT_LOCAL_VARS`) are deliberately **not**
|
|
1166
|
+
reserved — with **one exception**: an agent emitting `pr` (the canonical
|
|
1167
|
+
`agent → connector[converge] → wait[pr]` shape) writes the same node-local value the
|
|
1168
|
+
result field holds, and the retry reset correctly clears both — reserving them would
|
|
1169
|
+
forbid that flagship pattern. The exception is the agent completion-control **`status`**:
|
|
1170
|
+
the producer status gate reads that same node-local field as the node's completion status
|
|
1171
|
+
and accepts only `done`/`opened`/`skipped`, so an agent emit named `status` carrying any
|
|
1172
|
+
other routing value (`approved`, …) always escalates, and `status="done"` can never take an
|
|
1173
|
+
`approved` branch — the emit contract and the gate conflict. `status` is therefore reserved
|
|
1174
|
+
for an **`agent`** (a connector has no producer status gate, so a connector `status` emit
|
|
1175
|
+
stays allowed); the rest of the result set remains unreserved.
|
|
1176
|
+
|
|
1177
|
+
A **`human` node's required emit is gated fail-closed on completion** (PR #863 review,
|
|
1178
|
+
thread r4198662345). A human node has no worker self-report to producer-gate on, but its
|
|
1179
|
+
single emit can be a downstream-**required** fact — and the captured value is coerced and
|
|
1180
|
+
validated to the emit's declared type (`coerceFactValueFeel`), so an invalid entry becomes
|
|
1181
|
+
`null`. Without a completion gate the node ended and the unconditional edge activated the
|
|
1182
|
+
consumer with a `null` required fact. The compiler therefore grows a **completion gate**
|
|
1183
|
+
when the node's single emit is downstream-required: the user task's own output mapping
|
|
1184
|
+
computes a node-unique validity flag (`resumeValidVar`) from the *raw* captured field via
|
|
1185
|
+
the **same** per-type `resumeValueCondition` grammar the escalation resume enforces
|
|
1186
|
+
(presence included, so a blank required entry is invalid), and a post-task exclusive
|
|
1187
|
+
gateway routes a **valid** completion to the node end while an **invalid** one loops back
|
|
1188
|
+
to the human task for re-entry — the human analogue of the escalation resume's fail-closed
|
|
1189
|
+
re-park (a human node has no separate escalation twin to re-park onto). The gate is grown
|
|
1190
|
+
**only** when the single emit is required: a routing-only/unconsumed emit keeps the direct
|
|
1191
|
+
`task → end` flow (its `null` simply takes a guarded split's deadlock-safe default, exactly
|
|
1192
|
+
like the escalation resume's no-required-target case), and a no-emit acknowledgement node
|
|
1193
|
+
has nothing to validate.
|
|
1194
|
+
|
|
1042
1195
|
## 14. Open questions / future
|
|
1043
1196
|
|
|
1044
1197
|
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
completeEscalationAsAgent,
|
|
19
19
|
completeEscalationAsHuman,
|
|
20
20
|
completeUserTaskAttributed,
|
|
21
|
+
escalationFormId,
|
|
21
22
|
latestCompletion,
|
|
22
23
|
revertAgentCompletion,
|
|
23
24
|
type TaskCompletion,
|
|
@@ -884,6 +885,57 @@ test("validateEscalationVariables derives its contract from the canonical .form
|
|
|
884
885
|
assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
|
|
885
886
|
});
|
|
886
887
|
|
|
888
|
+
test("validateEscalationVariables enforces the `decision` select on a delivery-graph service-escalation twin (retry-node)", async () => {
|
|
889
|
+
// A delivery-graph service-node escalation twin (`…__esc`/`…__contract`) ALWAYS renders the fixed
|
|
890
|
+
// `delivery-escalation` form, whose `decision` select is {continue, retry}. The canonical completers
|
|
891
|
+
// must therefore reject a present-but-invalid `decision` (e.g. an agent/direct-API typo `retrry`),
|
|
892
|
+
// instead of silently computing `retryRequested=false` and taking the deadlock-safe Continue default
|
|
893
|
+
// — which would skip the node (PR #863 review — MEDIUM "Validate decision values" advisory).
|
|
894
|
+
const escTwin = "delivery-human-task__n1__esc";
|
|
895
|
+
const contractTwin = "delivery-human-task__n2__contract";
|
|
896
|
+
// present-but-invalid decision → rejected on BOTH twin kinds
|
|
897
|
+
assert(validateEscalationVariables(escTwin, { decision: "retrry" }) !== null, "invalid decision rejected on __esc twin");
|
|
898
|
+
assert(validateEscalationVariables(contractTwin, { decision: "nope" }) !== null, "invalid decision rejected on __contract twin");
|
|
899
|
+
// valid decisions pass
|
|
900
|
+
assertEquals(validateEscalationVariables(escTwin, { decision: "retry" }), null);
|
|
901
|
+
assertEquals(validateEscalationVariables(escTwin, { decision: "continue" }), null);
|
|
902
|
+
// decision OMITTED → still valid (defaults to Continue); the generic-form wait-escalation twins, which
|
|
903
|
+
// never submit `decision`, must not be false-rejected (the form has no required fields).
|
|
904
|
+
assertEquals(validateEscalationVariables(escTwin, { value: "something", escalationNote: "n" }), null);
|
|
905
|
+
// the bare per-node human task (NOT a twin) stays unenforced — it renders a different form per node.
|
|
906
|
+
assertEquals(validateEscalationVariables("delivery-human-task__n1", { decision: "retrry" }), null);
|
|
907
|
+
// a node literally named `esc` has base id `delivery-human-task__esc` — NOT misread as a twin.
|
|
908
|
+
assertEquals(validateEscalationVariables("delivery-human-task__esc", { decision: "retrry" }), null);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("escalationFormId derives the COMPILED form per twin kind — wait-gate twins get the generic form, not the retry form (PR #863 review)", () => {
|
|
912
|
+
// Regression guard (Copilot thread app/agentCompletion.ts:186): the bare `__esc` suffix is shared by a
|
|
913
|
+
// service-node timeout twin (retry-capable → `delivery-escalation`) AND a `wait`-gate twin (no retry
|
|
914
|
+
// path → `delivery-human-generic`). Inferring one form from the suffix misclassified the wait gate, so
|
|
915
|
+
// the #461 Tasks-inbox `form_key` fallback rendered a bogus "Retry this step" the wait process silently
|
|
916
|
+
// ignores. The compiler now stamps a `__wait` kind marker into the wait twin's id so the form is
|
|
917
|
+
// DERIVED from the id.
|
|
918
|
+
// service-node twins → retry-capable escalation form
|
|
919
|
+
assertEquals(escalationFormId("delivery-human-task__n1__esc"), "delivery-escalation");
|
|
920
|
+
assertEquals(escalationFormId("delivery-human-task__n2__contract"), "delivery-escalation");
|
|
921
|
+
// wait-gate twin (kind marker) → select-less generic form
|
|
922
|
+
assertEquals(escalationFormId("delivery-human-task__gate__esc__wait"), "delivery-human-generic");
|
|
923
|
+
// a bare per-node human task (variable form per node) has no static contract
|
|
924
|
+
assertEquals(escalationFormId("delivery-human-task__n1"), undefined);
|
|
925
|
+
// a node literally named `esc`/`esc__wait` is NOT misread as a twin (needs a non-empty base before the suffix)
|
|
926
|
+
assertEquals(escalationFormId("delivery-human-task__esc"), undefined);
|
|
927
|
+
assertEquals(escalationFormId("delivery-human-task__esc__wait"), undefined);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("validateEscalationVariables: a wait-gate twin is validated against the generic form (no `decision` select to enforce)", () => {
|
|
931
|
+
// The wait-gate twin renders the select-less generic form, so a `decision` value is out of contract
|
|
932
|
+
// there — but the generic form declares no `decision` allowed-set, so the completer does not enforce
|
|
933
|
+
// one (the wait process ignores `decision` entirely; there is no retry path to guard).
|
|
934
|
+
const waitTwin = "delivery-human-task__gate__esc__wait";
|
|
935
|
+
assertEquals(validateEscalationVariables(waitTwin, { value: "pkg@1.2.3" }), null, "a value-only wait-gate resume is valid");
|
|
936
|
+
assertEquals(validateEscalationVariables(waitTwin, {}), null, "the generic form has no required fields");
|
|
937
|
+
});
|
|
938
|
+
|
|
887
939
|
test("feature-escalation demands non-blank answer on the answer path, but not on the hidden abandon path", async () => {
|
|
888
940
|
// resolution=answer shows the conditional `answer` field, which is required → blank/missing rejected.
|
|
889
941
|
assert(
|
package/app/agentCompletion.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
|
|
|
24
24
|
import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban";
|
|
25
25
|
import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
|
|
26
26
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
27
|
-
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
27
|
+
import { DELIVERY_HUMAN_ELEMENT, ESCALATION_FORM, GENERIC_HUMAN_FORM, isDeliveryEscalationTwin, isDeliveryHumanElement, isDeliveryWaitEscalationTwin } from "./deliveryHuman.ts";
|
|
28
28
|
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
29
29
|
|
|
30
30
|
const now = () => new Date().toISOString();
|
|
@@ -165,17 +165,32 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
165
165
|
// contract is enforced instead by `bindHumanEmits` against the node's declared `emits[]` (binds are
|
|
166
166
|
// validated, not stringly — Decision 3/4), so `validateEscalationVariables` leaves it unenforced
|
|
167
167
|
// (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
|
|
168
|
+
// (Its SERVICE-node escalation TWINS — `…__esc`/`…__contract` — are different: they render a FIXED
|
|
169
|
+
// form, so `escalationFormId` maps them to `ESCALATION_FORM` via `isDeliveryEscalationTwin` rather
|
|
170
|
+
// than listing each per-node id here.)
|
|
168
171
|
};
|
|
169
172
|
|
|
170
173
|
/** The fixed `.form` linkage (a `zeebe:formDefinition formId`) that governs a fixed-form escalation
|
|
171
|
-
* kind's completion, or `undefined` for a kind with no static form (the delivery-graph `human`
|
|
172
|
-
* which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at
|
|
174
|
+
* kind's completion, or `undefined` for a kind with no static form (the per-node delivery-graph `human`
|
|
175
|
+
* task, which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at
|
|
176
|
+
* runtime). A delivery-graph SERVICE-node escalation TWIN (`…__esc`/`…__contract`), by contrast, ALWAYS
|
|
177
|
+
* renders the fixed {@link ESCALATION_FORM} — a strict superset of the generic form — so it DOES carry a
|
|
178
|
+
* static contract here (via {@link isDeliveryEscalationTwin}): this is what lets the canonical completers
|
|
179
|
+
* reject a present-but-invalid `decision` (continue/retry) on a retry-node escalation, while leaving the
|
|
180
|
+
* select-less generic-form twins unaffected (the form has no required fields, so they never false-reject).
|
|
173
181
|
* Exposed so the Tasks-inbox poller can denormalise a row's `form_key` from this SAME single source of
|
|
174
182
|
* truth when the raw `/v2/user-tasks/search` result omits the engine-resolved key (issue #461) — the
|
|
175
183
|
* REST gateway addresses a deployed form by value whether that value is a deploy key or an authored
|
|
176
184
|
* form id, so this id resolves the same deployed `.form` the completer validates against. */
|
|
177
185
|
export function escalationFormId(elementId: string): string | undefined {
|
|
178
|
-
|
|
186
|
+
// Derive the twin's COMPILED form from its id rather than inferring one form from the shared `__esc`
|
|
187
|
+
// suffix: a `wait`-gate twin (`…__esc__wait`, kind marker stamped by the compiler) renders the
|
|
188
|
+
// select-less GENERIC_HUMAN_FORM (no retry path); a service-node `__esc`/`__contract` twin renders the
|
|
189
|
+
// retry-capable ESCALATION_FORM. Mapping the wait twin to ESCALATION_FORM would denormalise a bogus
|
|
190
|
+
// "Retry this step" onto its Tasks row (the wait process silently ignores `decision`) whenever the
|
|
191
|
+
// engine omits `formKey` (PR #863 review, app/agentCompletion.ts:186).
|
|
192
|
+
if (isDeliveryWaitEscalationTwin(elementId)) return GENERIC_HUMAN_FORM;
|
|
193
|
+
return ESCALATION_FORM_BY_ELEMENT[elementId] ?? (isDeliveryEscalationTwin(elementId) ? ESCALATION_FORM : undefined);
|
|
179
194
|
}
|
|
180
195
|
|
|
181
196
|
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
@@ -261,7 +276,7 @@ export function validateEscalationVariables(
|
|
|
261
276
|
elementId: string,
|
|
262
277
|
variables: Record<string, unknown>,
|
|
263
278
|
): string | null {
|
|
264
|
-
const formId =
|
|
279
|
+
const formId = escalationFormId(elementId);
|
|
265
280
|
if (!formId) return null;
|
|
266
281
|
const { required, allowed, hideWhen } = formContract(formId);
|
|
267
282
|
for (const key of required) {
|
package/app/contracts.ts
CHANGED
|
@@ -559,6 +559,15 @@ export const WIRE_CONTRACTS = {
|
|
|
559
559
|
"The engine-native AgentInstance/AgentHistory READ path (issue #745/#747, umbrella #746 — the CONSUMER half of the PRODUCER `agentTask.agentDefinition` marker). The Cockpit HISTORICAL transcript + per-turn/instance metrics are sourced from the engine read model through the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstances`/`searchAgentInstanceHistory`/`getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563; the escalation's option (a) — NO `orchestration-cluster-api-js` fork / no second broker-REST client, so the read path is exercised by the testkit WASM double, which records read-as-absence — an empty list / null — while a live engine validates the behavioural parity). ONE narrow reader (`AgentHistoryReader`) + ONE projection (`listAgentInstances`/`readAgentHistory`) in app/agentic/agent-history.ts, served by `GET /agentic/agent-instances` + `GET /agentic/agent-instances/{agentInstanceKey}/history`. Correlation keys are the AGENT-INSTANCE / PROCESS-INSTANCE / ELEMENT-INSTANCE keys (the same #544 per-occupancy element-instance handle the relay correlation uses) — NEVER the slash-bearing `job:<jobKey>` relay stream id, so the #744 gateway-proxy bug class is moot for settled history. The token-granular relay stays the LIVE overlay only (settled history = engine, live tail = relay). Advisory read-only (ADR 0056): it observes the engine read model, never activates/completes a job or gates a sequence flow. The wire shapes (`AgentInstance`/`AgentInstanceList`/`AgentHistoryRecord`/`AgentHistory` + the metrics shapes) are declared in openapi.yaml and reuse the `@nanobpm/agentic/transcript` parity conversation grammar the transcript store already models. Consume this ONE read path — do not add a second agent-history client or a synonym endpoint.",
|
|
560
560
|
shape: "GET /agentic/agent-instances[?processInstanceKey&rootProcessInstanceKey&elementId&status] → AgentInstanceList; GET /agentic/agent-instances/{agentInstanceKey}/history[?role&loopIteration&elementInstanceKey] → AgentHistory",
|
|
561
561
|
},
|
|
562
|
+
"deliveryGraph.workerResult": {
|
|
563
|
+
category: "wire",
|
|
564
|
+
name: "deliveryGraph.workerResult",
|
|
565
|
+
owner: "app/deliveryGraph.ts",
|
|
566
|
+
semantics:
|
|
567
|
+
"The delivery-graph worker-completion RESULT envelope (issue #863) — the canonical set of variable names a fleet worker (agent/connector) or an escalation completion returns on a delivery node's job, which the deliveryGraph compiler declares node-LOCAL on the node's subProcess (and clears on retry) so a parallel node never overwrites a sibling's result and a retry never reads a stale one. Three sibling sets, all exported from app/deliveryGraph.ts (the validator's home, so the reserved-fact-name set derives from them without an import cycle): `AGENT_RESULT_LOCAL_VARS` (status/summary/question/output/error/pr/prUrl/pullRequest/branch/commits/exitCode/next_steps/issue/completed/pushed/truncated/agentCheckpoint/transcriptUrl/delta/approved/findings/commentUrl/hasDeviations/slicesMet/slicesReduced/slicesNotVerified/deviationsRaised/deviationsUnraised, plus the prompt-contract outputs dependsOn/adversarialFindings/adversarialSummary/scopeBlocked/scopeBlockReason/tasks/result/conflicts/failing/note), `CONNECTOR_RESULT_LOCAL_VARS` (connectorOutcome/connectorDedupeKey/connectorDetail), and `ESCALATION_LOCAL_VARS` (decision/value/escalationNote — the escalation-completion controls; the retry-guidance note is `escalationNote`, NOT the plain `note`, which is a plan.md WORKER result field that would otherwise be fed back as operator guidance on a note-less retry). These are job-completion outputs and escalation-form variables, NOT message payloads, so the scalar-only message-envelope rule does not apply (array-typed fields like dependsOn/tasks/conflicts/failing are legal here). A new worker/escalation result field MUST be added to the matching set here — never re-declared as a synonym list in the compiler or a worker — or it leaks to the shared root scope.",
|
|
568
|
+
shape:
|
|
569
|
+
"AGENT_RESULT_LOCAL_VARS: readonly string[] (28 base + 10 prompt-contract outputs); CONNECTOR_RESULT_LOCAL_VARS: readonly ['connectorOutcome','connectorDedupeKey','connectorDetail']; ESCALATION_LOCAL_VARS: readonly ['decision','value','escalationNote']",
|
|
570
|
+
},
|
|
562
571
|
} as const satisfies Record<string, WireContract>;
|
|
563
572
|
|
|
564
573
|
export const TYPE_CONTRACTS = {
|
|
@@ -480,6 +480,178 @@ 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
|
+
|
|
483
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)", () => {
|
|
484
656
|
const errors = validateDeliveryGraph({
|
|
485
657
|
nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" }, emits: [{ name: "resumeValid", type: "boolean" }] }],
|
|
@@ -722,6 +894,85 @@ test("invalid-job-type: an `agent.jobType` carrying attribute whitespace (LF) is
|
|
|
722
894
|
hasCode(errors, "invalid-job-type");
|
|
723
895
|
});
|
|
724
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
|
+
|
|
725
976
|
test("invalid-job-type: a clean `agent.jobType` (no XML-invalid characters, no attribute whitespace) passes validation", () => {
|
|
726
977
|
assertEquals(
|
|
727
978
|
validateDeliveryGraph({
|