@nanobpm/nano-workforce 0.111.1 → 0.113.0

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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.113.0](https://github.com/nanobpm/nano-workforce/compare/v0.112.0...v0.113.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * **delivery-graph:** `human` node as a scheduled user task that emits a typed fact (ADR 0005 S3) ([#389](https://github.com/nanobpm/nano-workforce/issues/389)) ([2266f9c](https://github.com/nanobpm/nano-workforce/commit/2266f9c36f194a7b1bfa995925968353131040e4)), closes [#378](https://github.com/nanobpm/nano-workforce/issues/378)
7
+
8
+ # [0.112.0](https://github.com/nanobpm/nano-workforce/compare/v0.111.1...v0.112.0) (2026-08-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * **plan-fanout:** implement-stage escalation net + Tasks-inbox projection ([#358](https://github.com/nanobpm/nano-workforce/issues/358), [#360](https://github.com/nanobpm/nano-workforce/issues/360)) ([#387](https://github.com/nanobpm/nano-workforce/issues/387)) ([c884074](https://github.com/nanobpm/nano-workforce/commit/c884074ca9266d5a17c44d3a1286bb667b43a73a))
14
+
1
15
  ## [0.111.1](https://github.com/nanobpm/nano-workforce/compare/v0.111.0...v0.111.1) (2026-08-20)
2
16
 
3
17
 
@@ -23,6 +23,7 @@
23
23
  import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
+ import { DELIVERY_HUMAN_ELEMENT } from "./deliveryHuman.ts";
26
27
 
27
28
  const now = () => new Date().toISOString();
28
29
 
@@ -70,6 +71,7 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
70
71
  "trial-merge-decision",
71
72
  "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
72
73
  "wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
74
+ DELIVERY_HUMAN_ELEMENT, // delivery-graph `human` node (ADR 0005 S3) — a scheduled user task, answerable by a human OR an agent (ADR 0046)
73
75
  ]);
74
76
 
75
77
  /** The `feature-blocked` operator user-task element id (feature.bpmn) — the native wait a run parks on
@@ -110,6 +112,14 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
110
112
  "wait-merge-answer": "pr-escalation",
111
113
  "feature-blocked": "feature-blocked",
112
114
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
115
+ // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
116
+ // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
117
+ // to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
118
+ // `resolveHumanForm`) — the S4 runner selects one at activation; this S3 slice deploys the generic
119
+ // form as the static default. Either way a single static required-field contract cannot fit it. Its typed-emit
120
+ // contract is enforced instead by `bindHumanEmits` against the node's declared `emits[]` (binds are
121
+ // validated, not stringly — Decision 3/4), so `validateEscalationVariables` leaves it unenforced
122
+ // (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
113
123
  };
114
124
 
115
125
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
@@ -91,8 +91,8 @@ function isDeliveryFactType(type: unknown): type is DeliveryFactType {
91
91
  * a test, a future internal use), a dotted fact name could otherwise make `<nodeId>.<fact>` resolution
92
92
  * ambiguous and quietly build the wrong DAG — undermining the trust boundary this validator exists to
93
93
  * hold. */
94
- const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
95
- const FACT_NAME_MAX_LENGTH = 128;
94
+ export const FACT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
95
+ export const FACT_NAME_MAX_LENGTH = 128;
96
96
 
97
97
  /** A node `id` must match openapi's `DeliveryNodeCommon.id` `^[A-Za-z_][A-Za-z0-9_.-]*$` and stay
98
98
  * within its 128-char cap. Re-enforced here INDEPENDENTLY of the OpenAPI shape gate because later
@@ -0,0 +1,306 @@
1
+ // Tests for the `human` delivery-graph node (ADR 0005, slice S3): form resolution (specific-else-
2
+ // generic + gated agent-router), typed-emit binding, and the #289 late-binding surfaces. Pure logic
3
+ // (no engine), plus a structural + cross-layer guard tying the committed BPMN body / `.form` files to
4
+ // the completer allowlist and the inbox read-model so the two can't silently diverge.
5
+
6
+ import { test } from "node:test";
7
+ import { readFileSync } from "node:fs";
8
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
9
+ import type { DeliveryFact } from "../nano-generated/api-io.d.ts";
10
+ import {
11
+ bindHumanEmits,
12
+ DELIVERY_HUMAN_ELEMENT,
13
+ deriveHumanCategory,
14
+ GENERIC_HUMAN_FORM,
15
+ HUMAN_ACK_FORM,
16
+ HUMAN_PUBLISH_FORM,
17
+ humanEmitBind,
18
+ needsAgentFormRouter,
19
+ normalizeEmits,
20
+ renderHumanEmitBrief,
21
+ resolveHumanForm,
22
+ } from "./deliveryHuman.ts";
23
+ import { ESCALATION_TASK_ELEMENTS } from "./agentCompletion.ts";
24
+ import { USER_TASK_KIND_LABELS } from "./userTasks.ts";
25
+
26
+ const artifact = (name = "resolvedArtifact"): DeliveryFact => ({ name, type: "artifact" });
27
+ const version = (name = "publishedVersion"): DeliveryFact => ({ name, type: "version" });
28
+ const str = (name: string): DeliveryFact => ({ name, type: "string" });
29
+
30
+ // ── Form resolution: explicit → category → generic → agent-router ──────────────────────────────
31
+
32
+ test("resolveHumanForm: an explicit formKey always wins, even over a category match", () => {
33
+ const r = resolveHumanForm({ emits: [artifact()], human: { formKey: "my-bespoke-form" } });
34
+ assertEquals(r.source, "explicit");
35
+ assertEquals(r.formKey, "my-bespoke-form");
36
+ assertEquals(r.category, null);
37
+ });
38
+
39
+ test("resolveHumanForm: a blank/whitespace explicit formKey does not count as explicit", () => {
40
+ const r = resolveHumanForm({ emits: [], human: { formKey: " " } });
41
+ assertEquals(r.source, "category");
42
+ assertEquals(r.formKey, HUMAN_ACK_FORM);
43
+ });
44
+
45
+ test("resolveHumanForm: no emits selects the ack (click-done) category form", () => {
46
+ const r = resolveHumanForm({ emits: [], human: {} });
47
+ assertEquals(r.source, "category");
48
+ assertEquals(r.category, "ack");
49
+ assertEquals(r.formKey, HUMAN_ACK_FORM);
50
+ });
51
+
52
+ test("resolveHumanForm: a single artifact emit selects the publish category form", () => {
53
+ const r = resolveHumanForm({ emits: [artifact()], human: {} });
54
+ assertEquals(r.source, "category");
55
+ assertEquals(r.category, "publish");
56
+ assertEquals(r.formKey, HUMAN_PUBLISH_FORM);
57
+ });
58
+
59
+ test("resolveHumanForm: a single bare-version emit falls through to the generic form", () => {
60
+ // The publish form captures a pkg@version `resolvedArtifact`, which fails `version` validation in
61
+ // bindHumanEmits — so a lone `version` must use the generic single-value form, not publish.
62
+ const r = resolveHumanForm({ emits: [version()], human: {} });
63
+ assertEquals(r.source, "generic");
64
+ assertEquals(r.formKey, GENERIC_HUMAN_FORM);
65
+ assertEquals(r.category, null);
66
+ });
67
+
68
+ test("resolveHumanForm: a single non-artifact scalar emit falls through to the generic form", () => {
69
+ const r = resolveHumanForm({ emits: [str("approvalNote")], human: {} });
70
+ assertEquals(r.source, "generic");
71
+ assertEquals(r.formKey, GENERIC_HUMAN_FORM);
72
+ assertEquals(r.category, null);
73
+ });
74
+
75
+ test("regression: a single version resolves to a form whose capture key binds against `version`", () => {
76
+ // Guards the class: the resolved form's canonical capture key must produce a value that coerces
77
+ // against the emitted fact's type. The publish form captures `resolvedArtifact` (pkg@version),
78
+ // which FAILS `version` coercion — so a lone version must route to the generic `value` form.
79
+ const emit = version("publishedVersion");
80
+ const r = resolveHumanForm({ emits: [emit], human: {} });
81
+ assertEquals(r.formKey, GENERIC_HUMAN_FORM);
82
+ // Generic form's `value` capture binds cleanly against the version fact.
83
+ assertEquals(bindHumanEmits([emit], { value: "1.4.0" }).errors, []);
84
+ // Whereas the publish form's `resolvedArtifact` (a pkg@version) would NOT — the mismatch avoided.
85
+ assert(
86
+ bindHumanEmits([emit], { resolvedArtifact: "@nanobpm/urban@0.54.0" }).errors.length > 0,
87
+ "a pkg@version resolvedArtifact must not satisfy a bare-version emit",
88
+ );
89
+ });
90
+
91
+ test("resolveHumanForm: two-plus heterogeneous emits with no form gate the agent-router (null form)", () => {
92
+ const node = { emits: [artifact(), str("changelog")], human: {} };
93
+ const r = resolveHumanForm(node);
94
+ assertEquals(r.source, "agent-router");
95
+ assertEquals(r.formKey, null);
96
+ assert(needsAgentFormRouter(node), "needsAgentFormRouter must agree the router fires");
97
+ });
98
+
99
+ test("resolveHumanForm: an explicit form suppresses the agent-router even with many emits", () => {
100
+ const node = { emits: [artifact(), str("changelog"), version("v")], human: { formKey: "multi" } };
101
+ assertEquals(resolveHumanForm(node).source, "explicit");
102
+ assert(!needsAgentFormRouter(node), "an explicit form must pre-empt the router");
103
+ });
104
+
105
+ test("deriveHumanCategory: ack for empty, publish for one artifact, null for a lone version/scalar", () => {
106
+ assertEquals(deriveHumanCategory([]), "ack");
107
+ assertEquals(deriveHumanCategory([artifact()]), "publish");
108
+ // A lone bare `version` is NOT publish — the publish form captures a pkg@version resolvedArtifact,
109
+ // which fails `version` coercion; it falls through to the generic single-value form.
110
+ assertEquals(deriveHumanCategory([version()]), null);
111
+ assertEquals(deriveHumanCategory([str("x")]), null);
112
+ assertEquals(deriveHumanCategory([artifact(), version()]), null);
113
+ });
114
+
115
+ test("normalizeEmits: drops malformed declarations (non-record, blank name, unknown type)", () => {
116
+ const emits = [
117
+ artifact("good"),
118
+ { name: "", type: "string" },
119
+ { name: "bad", type: "nope" },
120
+ "junk",
121
+ { type: "string" },
122
+ ] as unknown as DeliveryFact[];
123
+ const out = normalizeEmits({ emits });
124
+ assertEquals(out.length, 1);
125
+ assertEquals(out[0].name, "good");
126
+ });
127
+
128
+ test("normalizeEmits: drops names the S0 validator would reject (dotted/non-identifier, over-long, duplicate)", () => {
129
+ const emits = [
130
+ str("good"),
131
+ { name: "dotted.name", type: "string" }, // fails FACT_NAME_PATTERN (would make <nodeId>.<fact> ambiguous)
132
+ { name: "1leading", type: "string" }, // leading digit — not a bare identifier
133
+ { name: "has space", type: "string" }, // whitespace — not an identifier
134
+ { name: `${"x".repeat(129)}`, type: "string" }, // exceeds FACT_NAME_MAX_LENGTH (128)
135
+ { name: "good", type: "version" }, // duplicate name — first (string) wins, this is dropped
136
+ ] as unknown as DeliveryFact[];
137
+ const out = normalizeEmits({ emits });
138
+ assertEquals(out.length, 1);
139
+ assertEquals(out[0].name, "good");
140
+ assertEquals(out[0].type, "string");
141
+ });
142
+
143
+ test("normalizeEmits: a name at exactly the 128-char cap is kept", () => {
144
+ const emits = [{ name: "x".repeat(128), type: "string" }] as unknown as DeliveryFact[];
145
+ const out = normalizeEmits({ emits });
146
+ assertEquals(out.length, 1);
147
+ });
148
+
149
+ // ── Typed emit binding ─────────────────────────────────────────────────────────────────────────
150
+
151
+ test("bindHumanEmits: a no-emit node yields no facts and no errors regardless of captured form", () => {
152
+ const r = bindHumanEmits([], { note: "did it", value: "ignored" });
153
+ assertEquals(r.facts.length, 0);
154
+ assertEquals(r.errors.length, 0);
155
+ });
156
+
157
+ test("bindHumanEmits: a single artifact binds from the canonical resolvedArtifact capture key", () => {
158
+ const r = bindHumanEmits([artifact()], { resolvedArtifact: "@nanobpm/urban@0.54.0", note: "n" });
159
+ assertEquals(r.errors, []);
160
+ assertEquals(r.facts.length, 1);
161
+ assertEquals(r.facts[0].value, "@nanobpm/urban@0.54.0");
162
+ assertEquals(r.facts[0].type, "artifact");
163
+ });
164
+
165
+ test("bindHumanEmits: a single scalar binds from the generic form's `value` capture key", () => {
166
+ const r = bindHumanEmits([str("approvalNote")], { value: "ship it" });
167
+ assertEquals(r.errors, []);
168
+ assertEquals(r.facts[0].value, "ship it");
169
+ });
170
+
171
+ test("bindHumanEmits: a fact keyed by its own name wins over the canonical fallback", () => {
172
+ const r = bindHumanEmits([version("publishedVersion")], { publishedVersion: "1.4.0", value: "9.9.9" });
173
+ assertEquals(r.facts[0].value, "1.4.0");
174
+ });
175
+
176
+ test("bindHumanEmits: multi-emit binds each fact by its own name (no canonical fallback)", () => {
177
+ const emits = [artifact("art"), str("changelog")];
178
+ const r = bindHumanEmits(emits, { art: "@a/b@1.0.0", changelog: "notes" });
179
+ assertEquals(r.errors, []);
180
+ assertEquals(r.facts.length, 2);
181
+ });
182
+
183
+ test("bindHumanEmits: a missing declared fact is a path-qualified error", () => {
184
+ const r = bindHumanEmits([artifact("art")], { note: "n" });
185
+ assertEquals(r.facts.length, 0);
186
+ assertEquals(r.errors.length, 1);
187
+ assertStringIncludes(r.errors[0], "emits.art");
188
+ });
189
+
190
+ test("bindHumanEmits: an ill-typed artifact/version/url/number/boolean each errors", () => {
191
+ assertStringIncludes(bindHumanEmits([artifact("a")], { a: "no-at-sign" }).errors[0], "pkg@version");
192
+ assertStringIncludes(bindHumanEmits([version("v")], { v: "not a version" }).errors[0], "version");
193
+ assertStringIncludes(bindHumanEmits([{ name: "u", type: "url" }], { u: "not a url" }).errors[0], "URL");
194
+ assertStringIncludes(bindHumanEmits([{ name: "n", type: "number" }], { n: "abc" }).errors[0], "number");
195
+ assertStringIncludes(bindHumanEmits([{ name: "b", type: "boolean" }], { b: "maybe" }).errors[0], "boolean");
196
+ });
197
+
198
+ test("bindHumanEmits: an artifact with an ill-formed version segment errors", () => {
199
+ // A well-formed `pkg@version` shape but a version segment that is not a valid version must reject —
200
+ // the version segment validates the same way a bare `version` fact does (#263, shared VERSION_PATTERN).
201
+ assertStringIncludes(
202
+ bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@not-a-version" }).errors[0],
203
+ "pkg@version",
204
+ );
205
+ assertEquals(bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@not-a-version" }).facts.length, 0);
206
+ // A valid digit-led (optionally `v`-prefixed) version segment still passes.
207
+ assertEquals(bindHumanEmits([artifact("a")], { a: "@nanobpm/urban@0.54.0" }).facts[0].value, "@nanobpm/urban@0.54.0");
208
+ assertEquals(bindHumanEmits([artifact("a")], { a: "pkg@v2.0.0-rc.1" }).facts[0].value, "pkg@v2.0.0-rc.1");
209
+ });
210
+
211
+ test("bindHumanEmits: number/boolean/url coerce to canonical string serialisations", () => {
212
+ assertEquals(bindHumanEmits([{ name: "n", type: "number" }], { n: "42" }).facts[0].value, "42");
213
+ assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: true }).facts[0].value, "true");
214
+ // A string boolean is trimmed before validating (like version/artifact/url), so a generic textfield
215
+ // capture with surrounding whitespace still binds to the canonical serialisation.
216
+ assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: " true " }).facts[0].value, "true");
217
+ assertEquals(bindHumanEmits([{ name: "b", type: "boolean" }], { b: "false " }).facts[0].value, "false");
218
+ assertEquals(
219
+ bindHumanEmits([{ name: "u", type: "url" }], { u: "https://x.test/p" }).facts[0].value,
220
+ "https://x.test/p",
221
+ );
222
+ // A string fact is trimmed before returning (like version/artifact/url), so the canonical
223
+ // serialisation does not depend on incidental surrounding whitespace.
224
+ assertEquals(bindHumanEmits([{ name: "s", type: "string" }], { s: " foo " }).facts[0].value, "foo");
225
+ });
226
+
227
+ // ── Late-binding surfaces (#289) ─────────────────────────────────────────────────────────────────
228
+
229
+ test("humanEmitBind: builds a `<nodeId>.<fact>` qualified bind map (empty for a no-emit node)", () => {
230
+ const { facts } = bindHumanEmits([artifact("resolvedArtifact")], { resolvedArtifact: "@a/b@1.0.0" });
231
+ assertEquals(humanEmitBind("manual-publish", facts), { "manual-publish.resolvedArtifact": "@a/b@1.0.0" });
232
+ assertEquals(humanEmitBind("x", []), {});
233
+ });
234
+
235
+ test("renderHumanEmitBrief: empty for no facts; pins each name→value otherwise", () => {
236
+ assertEquals(renderHumanEmitBrief([]), "");
237
+ const { facts } = bindHumanEmits([artifact("resolvedArtifact")], { resolvedArtifact: "@nanobpm/urban@0.54.0" });
238
+ const brief = renderHumanEmitBrief(facts);
239
+ assertStringIncludes(brief, "Human-emitted facts");
240
+ assertStringIncludes(brief, "`resolvedArtifact` (artifact) → `@nanobpm/urban@0.54.0`");
241
+ });
242
+
243
+ test("renderHumanEmitBrief: neutralises backticks/newlines in a value so the inline-code span can't break or inject", () => {
244
+ const { facts } = bindHumanEmits([str("note")], { note: "a`b\nc" });
245
+ const brief = renderHumanEmitBrief(facts);
246
+ assertStringIncludes(brief, "`note` (string) → `a'b c`");
247
+ assert(!brief.includes("a`b"), "raw backtick must not survive into the inline-code span");
248
+ });
249
+
250
+ // ── Cross-layer / structural guards ──────────────────────────────────────────────────────────────
251
+
252
+ const bpmn = readFileSync("resources/processes/delivery-human.bpmn", "utf8");
253
+ const flat = bpmn.replace(/\s+/g, " ");
254
+
255
+ test("BPMN: the human node is a native SLA-bounded userTask backed by the generic form", () => {
256
+ const task = flat.match(
257
+ new RegExp(`<bpmn:userTask\\b[^>]*\\bid="${DELIVERY_HUMAN_ELEMENT}"[\\s\\S]*?</bpmn:userTask>`),
258
+ );
259
+ assert(task, `${DELIVERY_HUMAN_ELEMENT} must be a <bpmn:userTask>`);
260
+ assertStringIncludes(task![0], "<zeebe:userTask", "must be a native (Zeebe) user task");
261
+ assertStringIncludes(task![0], `formId="${GENERIC_HUMAN_FORM}"`, "must attach the generic typed-emit form");
262
+ assertStringIncludes(task![0], 'candidateGroups="operators"', "must surface to operators");
263
+ // The interrupting SLA boundary timer bounds the node — it nags/times out, never silently wedges.
264
+ const boundary = flat.match(
265
+ new RegExp(`<bpmn:boundaryEvent\\b[^>]*\\battachedToRef="${DELIVERY_HUMAN_ELEMENT}"[\\s\\S]*?</bpmn:boundaryEvent>`),
266
+ );
267
+ assert(boundary, "an SLA boundary timer must be attached to the human task");
268
+ assertStringIncludes(boundary![0], "=escalationSlaTimeout", "the SLA reuses the escalation timeout var");
269
+ assertStringIncludes(boundary![0], "<bpmn:timerEventDefinition", "the SLA arm must be a timer");
270
+ });
271
+
272
+ test("BPMN: humanOutcome is set on BOTH the completion and the SLA-timeout path", () => {
273
+ // Symmetry with readiness-gate.bpmn's `gateOutcome`: a caller reading process variables must be
274
+ // able to distinguish a completed human step from one the SLA timer escalated. The userTask sets
275
+ // "completed" on the normal path; the timeout end event must set "escalated" — otherwise the
276
+ // escalation path ends with humanOutcome unset and the two outcomes are indistinguishable.
277
+ assertStringIncludes(
278
+ flat,
279
+ '<zeebe:output source="=&#34;completed&#34;" target="humanOutcome" />',
280
+ "the completion path must set humanOutcome=completed",
281
+ );
282
+ const escalated = flat.match(
283
+ /<bpmn:endEvent\b[^>]*\bid="human-escalated"[\s\S]*?<\/bpmn:endEvent>/,
284
+ );
285
+ assert(escalated, "the SLA-timeout end event human-escalated must exist");
286
+ assertStringIncludes(
287
+ escalated![0],
288
+ '<zeebe:input source="=&#34;escalated&#34;" target="humanOutcome" />',
289
+ "the SLA-timeout path must set humanOutcome=escalated",
290
+ );
291
+ });
292
+
293
+ test("drift guard: the human element is completer-answerable and surfaces on the inbox", () => {
294
+ // The canonical completer refuses any user task outside ESCALATION_TASK_ELEMENTS, so a model that
295
+ // parks on delivery-human-task while the code doesn't accept it would deploy but be unanswerable
296
+ // (by human OR agent, ADR 0046) — the silent-drift failure this guard closes.
297
+ assert(
298
+ ESCALATION_TASK_ELEMENTS.has(DELIVERY_HUMAN_ELEMENT),
299
+ "ESCALATION_TASK_ELEMENTS must accept the delivery human node",
300
+ );
301
+ // And it must carry an inbox label, or the parked task is invisible on the Tasks cockpit.
302
+ assert(
303
+ typeof USER_TASK_KIND_LABELS[DELIVERY_HUMAN_ELEMENT] === "string",
304
+ "USER_TASK_KIND_LABELS must label the delivery human node",
305
+ );
306
+ });