@nanobpm/nano-workforce 0.132.0 → 0.133.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.
@@ -12,8 +12,11 @@ import {
12
12
  type BoundFact,
13
13
  type ConnectorDispatchResult,
14
14
  connectorDedupeKey,
15
+ convergeOnlyForTarget,
15
16
  dispatchConnector,
17
+ isConvergeTarget,
16
18
  } from "../../app/deliveryConnector.ts";
19
+ import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
17
20
 
18
21
  // Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
19
22
  // no static data-envelope to derive from): `target`/`dedupeKey`/`payload` are seeded from the node's
@@ -58,8 +61,52 @@ export function readConnectorInput(vars: In): {
58
61
  return { target, payload, boundFacts, warnings };
59
62
  }
60
63
 
64
+ /** JSON-stringify a user-controlled value for an error message, falling back to `String(value)` when
65
+ * the value is not JSON-serializable (e.g. a `BigInt` or a circular object throws, or a `Symbol` /
66
+ * `undefined` / function that `JSON.stringify` serializes to `undefined`) so the intended validation
67
+ * error is never masked by a serializer `TypeError` and the function always honours its `string`
68
+ * return type. */
69
+ export function safeStringify(value: unknown): string {
70
+ try {
71
+ const s = JSON.stringify(value);
72
+ return s === undefined ? String(value) : s;
73
+ } catch {
74
+ return String(value);
75
+ }
76
+ }
77
+
78
+ /** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
79
+ * `converge` / `converge-merge` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
80
+ * (fail CLOSED — a converge connector with no target PR is meaningless and could never enroll).
81
+ * `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge` → drive
82
+ * the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
83
+ * optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
84
+ * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage — the
85
+ * MVP sources `pr` as a literal (identical to how the `wait: pr` node targets a known PR), so no new
86
+ * fact plumbing is needed to ship. */
87
+ export function readConvergeInput(
88
+ target: string,
89
+ payload: Record<string, unknown> | null,
90
+ ): { parsed: ParsedPr; convergeOnly: boolean; dependsOn: string[] } {
91
+ const p = payload ?? {};
92
+ const parsed = parsePr(p.pr);
93
+ if (!parsed) {
94
+ throw new Error(
95
+ `delivery-connector: '${target}' target requires payload.pr as a parseable "owner/repo#N" ` +
96
+ `(got ${safeStringify(p.pr ?? null)})`,
97
+ );
98
+ }
99
+ const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
100
+ const dependsOn = Array.isArray(p.dependsOn) ? p.dependsOn.filter((d): d is string => typeof d === "string") : [];
101
+ return { parsed, convergeOnly, dependsOn };
102
+ }
103
+
61
104
  const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) => {
62
105
  const { target, payload, boundFacts, warnings } = readConnectorInput(job.variables);
106
+ // A `converge`/`converge-merge` target enrolls a PR into the shared convergence (+ merge) loop.
107
+ // Parse + validate its payload BEFORE claiming a ledger row, so a misconfigured converge node (no
108
+ // parseable `pr`) fails CLOSED without writing a junk dispatch row it could never act on.
109
+ const converge = isConvergeTarget(target) ? readConvergeInput(target, payload) : null;
63
110
  const dedupeKey = connectorDedupeKey({
64
111
  dedupeKey: job.variables.dedupeKey ?? null,
65
112
  processInstanceKey: job.processInstanceKey ?? null,
@@ -75,6 +122,46 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
75
122
  app.data,
76
123
  { dedupeKey, target, payload, boundFacts },
77
124
  new Date().toISOString(),
125
+ // For a `converge`/`converge-merge` target, the connector's REAL side effect is enrolling the PR
126
+ // into the shared convergence (+ merge) loop via `submitPr` — the SAME enrollment
127
+ // `workers/converge-feature` uses, no duplicated machinery. It is injected as the dispatch's
128
+ // action so it lives INSIDE the at-most-once + resume envelope (`dispatchConnector`): it fires only
129
+ // on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a worker restart /
130
+ // lost ack / graph resume that lands AFTER the PR has settled — NEVER re-runs it. This is what
131
+ // preserves the connector's at-most-once semantics against `submitPr`, which deliberately RE-OPENS a
132
+ // terminal PR (it only short-circuits a non-terminal row); an unconditional call outside the fence
133
+ // would flip a `merged`/`converged`/`abandoned` PR back to `converging` on redelivery.
134
+ //
135
+ // The action is ALSO terminal-safe (idempotent on RESUME). A still-`claimed` crashed claim is
136
+ // re-performed by `dispatchConnector` (it never recorded delivery), and its first attempt may have
137
+ // already enrolled the PR AND let the convergence/merge loop settle it. Because `submitPr` re-opens a
138
+ // terminal row, re-performing blindly would regress that settled PR — so the action first checks
139
+ // `isPrSettled` and NO-OPS when the PR row is already terminal. On a LIVE (non-terminal) row
140
+ // `submitPr`'s own `prKey` short-circuit (`alreadyRunning`) already makes the resume double-safe.
141
+ // `rootRequestKey` is the stable per-node `dedupeKey` (authored, else `<processInstanceKey>:<elementId>`),
142
+ // so the enrolled PR's lineage is deterministic across redeliveries.
143
+ converge
144
+ ? async () => {
145
+ if (await isPrSettled(app.data, converge.parsed.prKey)) {
146
+ // Resume against an already-settled PR: the enrollment already ran its course. Record the
147
+ // dispatch delivered WITHOUT re-opening the terminal PR (never regress a settled PR).
148
+ app.log.info("delivery-connector: enrollment skipped — PR already terminal (resume-safe)", {
149
+ target,
150
+ dedupeKey,
151
+ prKey: converge.parsed.prKey,
152
+ });
153
+ return { detail: `${converge.parsed.prKey} already terminal — enrollment skipped (at-most-once resume-safe)` };
154
+ }
155
+ await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey);
156
+ app.log.info("delivery-connector: enrolled PR into convergence loop", {
157
+ target,
158
+ dedupeKey,
159
+ prKey: converge.parsed.prKey,
160
+ convergeOnly: converge.convergeOnly,
161
+ });
162
+ return { detail: `enrolled ${converge.parsed.prKey} into convergence loop (convergeOnly=${converge.convergeOnly})` };
163
+ }
164
+ : undefined,
78
165
  );
79
166
  app.log.info("delivery-connector", { target, dedupeKey, outcome: result.connectorOutcome });
80
167
  return result;