@nanobpm/nano-workforce 0.131.1 → 0.133.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.
@@ -4,7 +4,83 @@
4
4
  // `payload`/`boundFacts` is coerced to null with a surfaced warning rather than passed through.
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals, assertThrows } from "#test-assert";
7
- import { readConnectorInput } from "./worker.ts";
7
+ import { PROCESS_ID } from "../../app/service.ts";
8
+ import { withTrackingViews } from "../../test/trackingViews.ts";
9
+ import handler, { readConnectorInput, readConvergeInput, safeStringify } from "./worker.ts";
10
+
11
+ function memTable(rows: Record<string, unknown>[], key: string) {
12
+ return {
13
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
14
+ all: () => Promise.resolve([...rows]),
15
+ find: (q: Record<string, unknown>) =>
16
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
17
+ findOne: (q: Record<string, unknown>) =>
18
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
19
+ insert: (r: Record<string, unknown>) => {
20
+ rows.push(r);
21
+ return Promise.resolve(r);
22
+ },
23
+ update: (k: unknown, patch: Record<string, unknown>) => {
24
+ const r = rows.find((x) => x[key] === k);
25
+ if (r) Object.assign(r, patch);
26
+ return Promise.resolve(r);
27
+ },
28
+ delete: (k: unknown) => {
29
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
30
+ return Promise.resolve();
31
+ },
32
+ };
33
+ }
34
+
35
+ /** A hermetic `app` over in-memory tables + a createInstance-capturing engine, with the GitHub
36
+ * transport forced off so `submitPr`'s best-effort meta fetch is skipped. Returns the created
37
+ * convergence-loop instances so a test can assert the exact enrollment `submitPr` performed. */
38
+ function fakeApp() {
39
+ const stores: Record<string, { rows: Record<string, unknown>[]; key: string }> = {
40
+ pull_requests: { rows: [], key: "pr_key" },
41
+ escalations: { rows: [], key: "id" },
42
+ pr_dependencies: { rows: [], key: "pr_key" },
43
+ delivery_connector_dispatches: { rows: [], key: "id" },
44
+ };
45
+ const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
46
+ let nextId = 1;
47
+ const data = {
48
+ table: withTrackingViews((name: string, key: string) => {
49
+ const store = stores[name] ?? { rows: [], key };
50
+ stores[name] ??= store;
51
+ // The ledger PK is auto-assigned on insert (mimics the RAD Table<T> autoincrement).
52
+ if (name === "delivery_connector_dispatches") {
53
+ const base = memTable(store.rows, store.key);
54
+ return { ...base, insert: (r: Record<string, unknown>) => {
55
+ const id = nextId++;
56
+ store.rows.push({ ...r, id });
57
+ return Promise.resolve(id);
58
+ } } as ReturnType<typeof memTable>;
59
+ }
60
+ return memTable(store.rows, store.key);
61
+ }),
62
+ };
63
+ const engine = {
64
+ createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
65
+ created.push(req);
66
+ return Promise.resolve({ processInstanceKey: `PI-${created.length}` });
67
+ },
68
+ };
69
+ const app = { data, engine, log: { info() {}, warn() {}, error() {} } };
70
+ return { app: app as unknown as Parameters<typeof handler>[1], stores, created };
71
+ }
72
+
73
+ function withGithubOff(run: () => Promise<void>): Promise<void> {
74
+ const prevMode = process.env.NANO_PR_GITHUB_TRANSPORT;
75
+ const prevTok = process.env.GITHUB_TOKEN;
76
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token"; // no token below -> fetchPrMeta returns null
77
+ delete process.env.GITHUB_TOKEN;
78
+ return run().finally(() => {
79
+ if (prevMode !== undefined) process.env.NANO_PR_GITHUB_TRANSPORT = prevMode;
80
+ else delete process.env.NANO_PR_GITHUB_TRANSPORT;
81
+ if (prevTok !== undefined) process.env.GITHUB_TOKEN = prevTok;
82
+ });
83
+ }
8
84
 
9
85
  test("readConnectorInput: a blank/missing target fails closed (a connector with no destination is meaningless)", () => {
10
86
  for (const target of [undefined, "", " "]) {
@@ -42,3 +118,165 @@ test("readConnectorInput: an array payload is rejected (arrays are not plain obj
42
118
  assertEquals(r.payload, null);
43
119
  assertEquals(r.warnings.length, 1);
44
120
  });
121
+
122
+ // --- converge / converge-merge targets: enroll a PR into the shared convergence loop (issue #500) ---
123
+
124
+ test("readConvergeInput: parses pr; convergeOnly defaults from the target; dependsOn is optional", () => {
125
+ // `converge-merge` drives the merge loop → convergeOnly defaults false.
126
+ const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" });
127
+ assertEquals(merge.parsed.prKey, "owner/repo#7");
128
+ assertEquals(merge.convergeOnly, false);
129
+ assertEquals(merge.dependsOn, []);
130
+ // `converge` is review-only → convergeOnly defaults true.
131
+ const conv = readConvergeInput("converge", { pr: "owner/repo#7" });
132
+ assertEquals(conv.convergeOnly, true);
133
+ });
134
+
135
+ test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
136
+ const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] });
137
+ assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
138
+ assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
139
+ });
140
+
141
+ test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
142
+ assertThrows(() => readConvergeInput("converge-merge", null), Error, "payload.pr");
143
+ assertThrows(() => readConvergeInput("converge-merge", {}), Error, "payload.pr");
144
+ assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }), Error, "payload.pr");
145
+ });
146
+
147
+ test("readConvergeInput: an unparseable pr whose value is not JSON-serializable still fails CLOSED with the intended error (not a serializer TypeError)", () => {
148
+ // `p.pr` is user-controlled payload data; a BigInt (or a circular object) makes JSON.stringify
149
+ // throw, which must NOT mask the intended "requires payload.pr" error.
150
+ assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }), Error, "payload.pr");
151
+ const circular: Record<string, unknown> = {};
152
+ circular.self = circular;
153
+ assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }), Error, "payload.pr");
154
+ });
155
+
156
+ test("safeStringify: always returns a string, even for values JSON.stringify serializes to undefined (Symbol/undefined/function)", () => {
157
+ // JSON.stringify returns `undefined` (WITHOUT throwing) for a Symbol, a bare undefined, or a
158
+ // function. safeStringify is typed `: string`, so it must fall back to String(value) rather than
159
+ // leak that `undefined` through and violate its own contract.
160
+ assertEquals(typeof safeStringify(Symbol("x")), "string");
161
+ assertEquals(typeof safeStringify(undefined), "string");
162
+ assertEquals(typeof safeStringify(() => 0), "string");
163
+ // A normal serializable value still round-trips through JSON.stringify.
164
+ assertEquals(safeStringify({ a: 1 }), '{"a":1}');
165
+ });
166
+
167
+ test("handler: a `converge-merge` connector enrolls the PR into the convergence loop via submitPr (row + started convergence-loop instance)", async () => {
168
+ await withGithubOff(async () => {
169
+ const { app, stores, created } = fakeApp();
170
+ await handler(
171
+ { variables: { target: "converge-merge", payload: { pr: "owner/repo#7" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
172
+ app,
173
+ );
174
+ // The identical row + loop a `converge-feature` enrollment produces.
175
+ assertEquals(stores.pull_requests.rows.length, 1, "exactly one pull_requests row is registered");
176
+ const pr = stores.pull_requests.rows[0];
177
+ assertEquals(pr.pr_key, "owner/repo#7");
178
+ assertEquals(pr.status, "converging");
179
+ assertEquals(created.length, 1, "the convergence-loop instance was started");
180
+ assertEquals(created[0]?.processDefinitionId, PROCESS_ID);
181
+ // converge-merge → not converge-only, so the merge loop is authorised.
182
+ assertEquals(created[0]?.variables?.convergeOnly, false);
183
+ assertEquals(created[0]?.variables?.prKey, "owner/repo#7");
184
+ // Lineage roots on the stable per-node dedupe key (graph-derived here).
185
+ assertEquals(created[0]?.variables?.rootRequestKey, "PI-graph:n2");
186
+ // The connector ledger still recorded the dispatch (the at-most-once fence around the stub).
187
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1);
188
+ });
189
+ });
190
+
191
+ test("handler: a `converge` connector enrolls converge-ONLY (stops at converged, never hands to the merge loop)", async () => {
192
+ await withGithubOff(async () => {
193
+ const { app, created } = fakeApp();
194
+ await handler(
195
+ { variables: { target: "converge", payload: { pr: "owner/repo#8" } }, processInstanceKey: "PI-g", elementId: "n1" } as never,
196
+ app,
197
+ );
198
+ assertEquals(created.length, 1);
199
+ assertEquals(created[0]?.variables?.convergeOnly, true);
200
+ });
201
+ });
202
+
203
+ test("handler: re-dispatch (at-least-once redelivery) does NOT double-enroll (ledger fence + submitPr prKey idempotency)", async () => {
204
+ await withGithubOff(async () => {
205
+ const { app, stores, created } = fakeApp();
206
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#9" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
207
+ await handler(job, app);
208
+ await handler(job, app); // the graph resumes / the job is redelivered
209
+ assertEquals(stores.pull_requests.rows.length, 1, "still exactly one PR row");
210
+ assertEquals(created.length, 1, "the convergence-loop is started exactly once (submitPr collapses the repeat)");
211
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "one ledger row — the dispatch fence deduped");
212
+ });
213
+ });
214
+
215
+ test("handler: a redelivery AFTER the PR reached a terminal state does NOT re-enroll (the connector's at-most-once fence, not submitPr's short-circuit)", async () => {
216
+ await withGithubOff(async () => {
217
+ const { app, stores, created } = fakeApp();
218
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#11" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
219
+ await handler(job, app);
220
+ assertEquals(created.length, 1, "the first delivery enrolls the PR");
221
+ // The convergence (+ merge) loop ran to completion; the PR row is now TERMINAL.
222
+ stores.pull_requests.rows[0].status = "merged";
223
+ // An at-least-once redelivery (worker restart / lost ack / graph resume) lands AFTER settlement.
224
+ // `submitPr` deliberately RE-OPENS a terminal row, so the connector must not call it again — the
225
+ // node instance already fired exactly once, and the ledger fence must suppress the redelivery.
226
+ await handler(job, app);
227
+ assertEquals(created.length, 1, "the settled PR is NOT re-enrolled — no second convergence-loop instance");
228
+ assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR is never flipped back to converging");
229
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "still one ledger row — the connector fence deduped the redelivery");
230
+ });
231
+ });
232
+
233
+ test("handler: a converge redelivery whose prior claim CRASHED before recording delivery still enrolls (resume, not lost)", async () => {
234
+ await withGithubOff(async () => {
235
+ const { app, stores, created } = fakeApp();
236
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#12" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
237
+ // Simulate a crash BETWEEN claiming the ledger row and recording delivery: a `claimed` row with no
238
+ // enrollment yet. The redelivery must RESUME (perform the enrollment), never dedupe on the un-acted claim.
239
+ stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
240
+ await handler(job, app);
241
+ assertEquals(created.length, 1, "the crashed claim is resumed — the enrollment fires exactly once now");
242
+ assertEquals(stores.pull_requests.rows.length, 1, "the PR was enrolled on resume");
243
+ assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered");
244
+ });
245
+ });
246
+
247
+ test("handler: a crash-window RESUME whose PR already SETTLED to terminal does NOT re-open it (the enrollment action is terminal-safe)", async () => {
248
+ await withGithubOff(async () => {
249
+ const { app, stores, created } = fakeApp();
250
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#13" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
251
+ // The first attempt claimed the ledger AND enrolled the PR, which then ran the convergence (+ merge)
252
+ // loop to completion (`merged`) — but the worker crashed BEFORE recording `delivered`, leaving a
253
+ // still-`claimed` row. A redelivery RESUMES that claim (perform-again, since it never recorded done).
254
+ stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
255
+ stores.pull_requests.rows.push({ pr_key: "owner/repo#13", status: "merged" });
256
+ // `submitPr` deliberately RE-OPENS a terminal PR (it only short-circuits a NON-terminal row), so a
257
+ // resumed re-perform would flip the settled PR back to `converging`. The enrollment action must be
258
+ // terminal-safe: on resume against an already-settled PR it no-ops (no submitPr, no new instance).
259
+ await handler(job, app);
260
+ assertEquals(created.length, 0, "the settled PR is NOT re-enrolled on resume — no new convergence-loop instance");
261
+ assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR stays terminal (never flipped back to converging)");
262
+ assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered (the dispatch is done, no side effect was needed)");
263
+ });
264
+ });
265
+
266
+ test("handler: a misconfigured converge connector (no parseable pr) fails CLOSED and writes NO ledger row", async () => {
267
+ await withGithubOff(async () => {
268
+ const { app, stores, created } = fakeApp();
269
+ let threw = false;
270
+ try {
271
+ await handler(
272
+ { variables: { target: "converge-merge", payload: { pr: "garbage" } }, processInstanceKey: "PI", elementId: "n1" } as never,
273
+ app,
274
+ );
275
+ } catch {
276
+ threw = true;
277
+ }
278
+ assert(threw, "a converge connector with no target PR fails closed");
279
+ assertEquals(created.length, 0, "no convergence-loop instance is started");
280
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 0, "no junk ledger row is claimed");
281
+ });
282
+ });
@@ -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;