@nanobpm/nano-workforce 0.112.0 → 0.114.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.
@@ -0,0 +1,87 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" id="Definitions_nano_workforce_delivery_human" targetNamespace="http://nanobpm.io/nano-workforce">
3
+ <bpmn:process id="delivery-human" name="Delivery graph — human node (scheduled user task)" isExecutable="true">
4
+ <bpmn:startEvent id="Start" name="Human node scheduled">
5
+ <bpmn:outgoing>f_start</bpmn:outgoing>
6
+ </bpmn:startEvent>
7
+ <bpmn:userTask id="delivery-human-task" name="Delivery: human step (now do X)">
8
+ <bpmn:extensionElements>
9
+ <zeebe:formDefinition formId="delivery-human-generic" />
10
+ <zeebe:userTask />
11
+ <zeebe:assignmentDefinition candidateGroups="operators" assignee="=if (is defined(escalationAssignee) and escalationAssignee != null and trim(string(escalationAssignee)) != &#34;&#34;) then escalationAssignee else null" />
12
+ <zeebe:ioMapping>
13
+ <zeebe:output source="=&#34;completed&#34;" target="humanOutcome" />
14
+ <zeebe:output source="=if (is defined(value)) then value else null" target="humanEmitValue" />
15
+ <zeebe:output source="=if (is defined(resolvedArtifact)) then resolvedArtifact else null" target="humanEmitArtifact" />
16
+ <zeebe:output source="=if (is defined(note)) then note else null" target="humanNote" />
17
+ </zeebe:ioMapping>
18
+ </bpmn:extensionElements>
19
+ <bpmn:incoming>f_start</bpmn:incoming>
20
+ <bpmn:outgoing>f_done</bpmn:outgoing>
21
+ </bpmn:userTask>
22
+ <bpmn:boundaryEvent id="be_delivery_human_sla" name="SLA elapsed" attachedToRef="delivery-human-task">
23
+ <bpmn:outgoing>f_sla</bpmn:outgoing>
24
+ <bpmn:timerEventDefinition id="ted_delivery_human_sla">
25
+ <bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=escalationSlaTimeout</bpmn:timeDuration>
26
+ </bpmn:timerEventDefinition>
27
+ </bpmn:boundaryEvent>
28
+ <bpmn:endEvent id="human-done" name="Human step done (fact emitted)">
29
+ <bpmn:incoming>f_done</bpmn:incoming>
30
+ </bpmn:endEvent>
31
+ <bpmn:endEvent id="human-escalated" name="Human step timed out (escalated)">
32
+ <bpmn:extensionElements>
33
+ <zeebe:ioMapping>
34
+ <zeebe:input source="=&#34;escalated&#34;" target="humanOutcome" />
35
+ </zeebe:ioMapping>
36
+ </bpmn:extensionElements>
37
+ <bpmn:incoming>f_sla</bpmn:incoming>
38
+ </bpmn:endEvent>
39
+ <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="delivery-human-task" />
40
+ <bpmn:sequenceFlow id="f_done" sourceRef="delivery-human-task" targetRef="human-done" />
41
+ <bpmn:sequenceFlow id="f_sla" sourceRef="be_delivery_human_sla" targetRef="human-escalated" />
42
+ </bpmn:process>
43
+ <bpmndi:BPMNDiagram id="BPMNDiagram_delivery-human">
44
+ <bpmndi:BPMNPlane id="BPMNPlane_delivery-human" bpmnElement="delivery-human">
45
+ <bpmndi:BPMNShape id="BPMNShape_Start" bpmnElement="Start">
46
+ <dc:Bounds x="80" y="102" width="36" height="36" />
47
+ <bpmndi:BPMNLabel>
48
+ <dc:Bounds x="60" y="143" width="76" height="28" />
49
+ </bpmndi:BPMNLabel>
50
+ </bpmndi:BPMNShape>
51
+ <bpmndi:BPMNShape id="BPMNShape_delivery-human-task" bpmnElement="delivery-human-task">
52
+ <dc:Bounds x="216" y="80" width="100" height="80" />
53
+ </bpmndi:BPMNShape>
54
+ <bpmndi:BPMNShape id="BPMNShape_human-done" bpmnElement="human-done">
55
+ <dc:Bounds x="416" y="102" width="36" height="36" />
56
+ <bpmndi:BPMNLabel>
57
+ <dc:Bounds x="396" y="143" width="76" height="42" />
58
+ </bpmndi:BPMNLabel>
59
+ </bpmndi:BPMNShape>
60
+ <bpmndi:BPMNShape id="BPMNShape_human-escalated" bpmnElement="human-escalated">
61
+ <dc:Bounds x="416" y="262" width="36" height="36" />
62
+ <bpmndi:BPMNLabel>
63
+ <dc:Bounds x="394" y="303" width="81" height="42" />
64
+ </bpmndi:BPMNLabel>
65
+ </bpmndi:BPMNShape>
66
+ <bpmndi:BPMNShape id="BPMNShape_be_delivery_human_sla" bpmnElement="be_delivery_human_sla">
67
+ <dc:Bounds x="248" y="142" width="36" height="36" />
68
+ <bpmndi:BPMNLabel>
69
+ <dc:Bounds x="164" y="183" width="84" height="14" />
70
+ </bpmndi:BPMNLabel>
71
+ </bpmndi:BPMNShape>
72
+ <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
73
+ <di:waypoint x="116" y="120" />
74
+ <di:waypoint x="216" y="120" />
75
+ </bpmndi:BPMNEdge>
76
+ <bpmndi:BPMNEdge id="BPMNEdge_f_done" bpmnElement="f_done">
77
+ <di:waypoint x="316" y="120" />
78
+ <di:waypoint x="416" y="120" />
79
+ </bpmndi:BPMNEdge>
80
+ <bpmndi:BPMNEdge id="BPMNEdge_f_sla" bpmnElement="f_sla">
81
+ <di:waypoint x="266" y="178" />
82
+ <di:waypoint x="266" y="280" />
83
+ <di:waypoint x="416" y="280" />
84
+ </bpmndi:BPMNEdge>
85
+ </bpmndi:BPMNPlane>
86
+ </bpmndi:BPMNDiagram>
87
+ </bpmn:definitions>
@@ -50,11 +50,12 @@ test("every corpus model is either ported or has a documented blocker", () => {
50
50
  "convergence-loop",
51
51
  "merge-loop",
52
52
  "plan-fanout",
53
+ "delivery-human",
53
54
  ];
54
55
  assertEquals(
55
56
  PORTS.map((p) => p.model),
56
57
  expected,
57
- "PORTS must cover all seven goldens in the epic's authoring order",
58
+ "PORTS must cover all eight goldens in the epic's authoring order",
58
59
  );
59
60
  for (const port of PORTS) {
60
61
  assert(
@@ -66,14 +67,21 @@ test("every corpus model is either ported or has a documented blocker", () => {
66
67
 
67
68
  // The blockers are not guesses — prove each against the goldens themselves.
68
69
  //
69
- // CLASS 1 — five goldens have MORE THAN ONE top-level start and/or end event,
70
+ // CLASS 1 — six goldens have MORE THAN ONE top-level start and/or end event,
70
71
  // which the published `@nanobpm/workflow@0.12.0` compiler (a single
71
72
  // `<startEvent id="Start">` + single `<endEvent id="End">`) cannot derive.
72
73
  test("class-1 blocked goldens genuinely have multiple top-level start/end events", () => {
73
74
  const countTag = (xml: string, tag: string): number =>
74
75
  (xml.match(new RegExp(`<bpmn:${tag}\\b`, "g")) ?? []).length;
75
76
 
76
- const multiStartEndBlocked = new Set(["spine-demo", "readiness-gate", "feature", "merge-loop", "plan-fanout"]);
77
+ const multiStartEndBlocked = new Set([
78
+ "spine-demo",
79
+ "readiness-gate",
80
+ "feature",
81
+ "merge-loop",
82
+ "plan-fanout",
83
+ "delivery-human",
84
+ ]);
77
85
  for (const model of multiStartEndBlocked) {
78
86
  const xml = readFileSync(goldenPath(model), "utf8");
79
87
  const starts = countTag(xml, "startEvent");
@@ -9,13 +9,13 @@
9
9
  // the structurally-derivable goldens at full whole-model parity, park the rest
10
10
  // pending an upstream construct, and do NOT relax to node-surface parity):
11
11
  //
12
- // • `retro` is a GREEN whole-model parity port (see below). The remaining six
12
+ // • `retro` is a GREEN whole-model parity port (see below). The remaining seven
13
13
  // goldens are `blockedReason`-parked, in TWO distinct classes, each awaiting
14
14
  // an upstream `@nanobpm/workflow` (nano-ide) construct + re-release — never a
15
15
  // golden edit and never relaxed acceptance:
16
16
  //
17
17
  // (1) MULTI top-level start/end (spine-demo, readiness-gate, feature,
18
- // merge-loop, plan-fanout). `@nanobpm/workflow` derives EXACTLY
18
+ // merge-loop, plan-fanout, delivery-human). `@nanobpm/workflow` derives EXACTLY
19
19
  // ONE `<bpmn:startEvent id="Start">` + ONE `<bpmn:endEvent id="End">`,
20
20
  // converging every dangler into that single end (see `Compiler.compile`
21
21
  // in the package's `declarative.ts`). Needs a terminal/explicit-end
@@ -146,7 +146,7 @@ const MULTI_START_END_BLOCK =
146
146
  "cannot reproduce. Awaits an upstream terminal/explicit-end (+ multi-start) " +
147
147
  "construct in @nanobpm/workflow (nano-ide).";
148
148
 
149
- /** All seven ports, keyed by model, in the epic's stated authoring order. */
149
+ /** All ports, keyed by model, in the epic's stated authoring order. */
150
150
  export const PORTS: readonly PortEntry[] = [
151
151
  { model: "retro", flow: retroFlow },
152
152
  {
@@ -183,4 +183,8 @@ export const PORTS: readonly PortEntry[] = [
183
183
  model: "plan-fanout",
184
184
  blockedReason: `${MULTI_START_END_BLOCK} (plan-fanout: 3 starts, 3 ends)`,
185
185
  },
186
+ {
187
+ model: "delivery-human",
188
+ blockedReason: `${MULTI_START_END_BLOCK} (delivery-human: 1 start, 2 ends)`,
189
+ },
186
190
  ];
@@ -0,0 +1,44 @@
1
+ // Unit coverage for the pr.delivery-connector worker's input validation (workers/delivery-connector/worker.ts).
2
+ // Job variables are UNTYPED at runtime, so the worker must not forward a misconfigured node's garbage
3
+ // into the (side-effecting) connector I/O surface: a blank `target` fails CLOSED, and a wrong-shaped
4
+ // `payload`/`boundFacts` is coerced to null with a surfaced warning rather than passed through.
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals, assertThrows } from "#test-assert";
7
+ import { readConnectorInput } from "./worker.ts";
8
+
9
+ test("readConnectorInput: a blank/missing target fails closed (a connector with no destination is meaningless)", () => {
10
+ for (const target of [undefined, "", " "]) {
11
+ assertThrows(() => readConnectorInput({ target }), Error, "target");
12
+ }
13
+ });
14
+
15
+ test("readConnectorInput: a valid target is trimmed and passed through; well-shaped payload/boundFacts survive", () => {
16
+ const facts = [{ from: "n1", name: "mergedSha", value: "abc" }];
17
+ const r = readConnectorInput({ target: " slack ", payload: { channel: "#rel" }, boundFacts: facts });
18
+ assertEquals(r.target, "slack");
19
+ assertEquals(r.payload, { channel: "#rel" });
20
+ assertEquals(r.boundFacts, facts);
21
+ assertEquals(r.warnings.length, 0);
22
+ });
23
+
24
+ test("readConnectorInput: absent payload/boundFacts default to null with no warning", () => {
25
+ const r = readConnectorInput({ target: "slack" });
26
+ assertEquals(r.payload, null);
27
+ assertEquals(r.boundFacts, null);
28
+ assertEquals(r.warnings.length, 0);
29
+ });
30
+
31
+ test("readConnectorInput: a non-object payload / non-array boundFacts is coerced to null and warned (never forwarded)", () => {
32
+ const r = readConnectorInput({ target: "slack", payload: "oops" as unknown as Record<string, unknown>, boundFacts: "nope" as unknown as [] });
33
+ assertEquals(r.payload, null, "a scalar payload never reaches the connector surface");
34
+ assertEquals(r.boundFacts, null, "a non-array boundFacts never reaches the connector surface");
35
+ assertEquals(r.warnings.length, 2, "both coercions are surfaced for logging (not silent)");
36
+ assert(r.warnings.some((w) => w.includes("payload")), "the payload coercion is named");
37
+ assert(r.warnings.some((w) => w.includes("boundFacts")), "the boundFacts coercion is named");
38
+ });
39
+
40
+ test("readConnectorInput: an array payload is rejected (arrays are not plain objects)", () => {
41
+ const r = readConnectorInput({ target: "slack", payload: [1, 2, 3] as unknown as Record<string, unknown> });
42
+ assertEquals(r.payload, null);
43
+ assertEquals(r.warnings.length, 1);
44
+ });
@@ -0,0 +1,83 @@
1
+ // pr.delivery-connector — the delivery-graph `connector` node's engine-native execution body (ADR 0005
2
+ // slice S4). The service-task half of a compiled `connector` subProcess: it drives the forward-declared
3
+ // connector I/O surface AT-MOST-ONCE per dedupe key, so the engine's at-least-once job delivery (a
4
+ // worker/hub restart, a lost ack, a graph resume) can never double-fire the side effect (Decision 7).
5
+ //
6
+ // All the idempotency logic lives in `app/deliveryConnector.ts` (the durable-fence ledger + claim→act
7
+ // envelope) so it is unit-testable without the engine and shares ONE implementation with any other
8
+ // caller; this worker is the thin engine adapter that resolves the effective dedupe key from the job's
9
+ // author-supplied `dedupeKey` or its stable engine identity (`processInstanceKey:elementId`).
10
+ import type { AppJobHandler } from "@nanobpm/urban";
11
+ import {
12
+ type BoundFact,
13
+ type ConnectorDispatchResult,
14
+ connectorDedupeKey,
15
+ dispatchConnector,
16
+ } from "../../app/deliveryConnector.ts";
17
+
18
+ // Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
19
+ // no static data-envelope to derive from): `target`/`dedupeKey`/`payload` are seeded from the node's
20
+ // config, `boundFacts` is the late-bound list of upstream producers' emitted facts.
21
+ interface In extends Record<string, unknown> {
22
+ target?: string;
23
+ dedupeKey?: string | null;
24
+ payload?: Record<string, unknown> | null;
25
+ boundFacts?: BoundFact[] | null;
26
+ }
27
+
28
+ /** A plain (non-array, non-null) object — the only shape a connector `payload` may take. */
29
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
30
+ return typeof v === "object" && v !== null && !Array.isArray(v);
31
+ }
32
+
33
+ /** Validate + normalise the untyped job variables into the shape the connector surface accepts. Job
34
+ * variables are untyped at runtime, so a misconfigured node (or a hand-seeded instance) could hand us a
35
+ * blank `target`, a scalar `payload`, or a non-array `boundFacts`. Fail CLOSED on a blank `target` (a
36
+ * connector with no destination is meaningless and would write a junk ledger row); coerce a wrong-shaped
37
+ * `payload`/`boundFacts` to `null` (they are optional) and surface the coercion via `warnings` so the
38
+ * caller can log it rather than silently pass garbage into the I/O surface. */
39
+ export function readConnectorInput(vars: In): {
40
+ target: string;
41
+ payload: Record<string, unknown> | null;
42
+ boundFacts: BoundFact[] | null;
43
+ warnings: string[];
44
+ } {
45
+ const target = typeof vars.target === "string" ? vars.target.trim() : "";
46
+ if (!target) throw new Error("delivery-connector: 'target' is required (blank connector target)");
47
+ const warnings: string[] = [];
48
+ let payload: Record<string, unknown> | null = null;
49
+ if (vars.payload != null) {
50
+ if (isPlainObject(vars.payload)) payload = vars.payload;
51
+ else warnings.push("payload is not a plain object — coerced to null");
52
+ }
53
+ let boundFacts: BoundFact[] | null = null;
54
+ if (vars.boundFacts != null) {
55
+ if (Array.isArray(vars.boundFacts)) boundFacts = vars.boundFacts;
56
+ else warnings.push("boundFacts is not an array — coerced to null");
57
+ }
58
+ return { target, payload, boundFacts, warnings };
59
+ }
60
+
61
+ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) => {
62
+ const { target, payload, boundFacts, warnings } = readConnectorInput(job.variables);
63
+ const dedupeKey = connectorDedupeKey({
64
+ dedupeKey: job.variables.dedupeKey ?? null,
65
+ processInstanceKey: job.processInstanceKey ?? null,
66
+ elementId: job.elementId ?? null,
67
+ });
68
+ if (!dedupeKey) {
69
+ // No author key AND no engine identity to derive one — un-dedupable. Fail closed rather than
70
+ // perform a side effect we could not make idempotent (never double-fire).
71
+ throw new Error("delivery-connector: no dedupe key (author-supplied or graph-derived) available");
72
+ }
73
+ for (const w of warnings) app.log.info("delivery-connector: input coerced", { target, dedupeKey, warning: w });
74
+ const result = await dispatchConnector(
75
+ app.data,
76
+ { dedupeKey, target, payload, boundFacts },
77
+ new Date().toISOString(),
78
+ );
79
+ app.log.info("delivery-connector", { target, dedupeKey, outcome: result.connectorOutcome });
80
+ return result;
81
+ };
82
+
83
+ export default handler;