@nanobpm/nano-workforce 0.113.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,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;