@nanobpm/nano-workforce 0.113.0 → 0.114.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.
@@ -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;
@@ -50,7 +50,7 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
50
50
  }
51
51
 
52
52
  const handler: AppJobHandler<In> = async (job, app) => {
53
- const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
53
+ const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl, headSha, scopeBlock } = job.variables;
54
54
  // `status` drives the escalation kind (control flow); a blank/absent status is an
55
55
  // unclassified escalation -> a question needing input. `question` is returned as a
56
56
  // process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
@@ -116,6 +116,13 @@ const handler: AppJobHandler<In> = async (job, app) => {
116
116
  worker,
117
117
  status: "open",
118
118
  asked_at: now,
119
+ // Bind a scope-integrity escalation to the reviewed commit (issue #395) so the converge-gate
120
+ // can honour a human answer as an override for THIS HEAD instead of re-deriving the block from
121
+ // the PR body and re-escalating forever. Only the scope-integrity arm passes these; every other
122
+ // arm leaves them absent (→ head_sha NULL, scope_block DEFAULT 0), so the override door opens
123
+ // exclusively for the block the human can actually answer.
124
+ head_sha: headSha,
125
+ scope_block: scopeBlock === true ? 1 : 0,
119
126
  });
120
127
  await app.data.table("pull_requests", "pr_key").update(prKey, {
121
128
  status: "escalated",