@edgehero/pi-dispatch-receiver 1.3.0 → 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Webhook receiver for pi-dispatch: the always-on edge that verifies GitHub, GitLab, Forgejo and Azure DevOps deliveries and enqueues (at most) one job per event for the worker.",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@octokit/webhooks": "14.2.0",
48
- "@edgehero/pi-dispatch": "^1.4.0",
48
+ "@edgehero/pi-dispatch": "^1.6.0",
49
49
  "bullmq": "5.80.4",
50
50
  "ioredis": "5.11.1"
51
51
  }
package/src/config.mjs CHANGED
@@ -293,9 +293,9 @@ function loadTriggers(env, readFile, fileExists) {
293
293
  }
294
294
  const group = groups[run.kind];
295
295
  if (on.type === "label") {
296
- group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository });
296
+ group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository });
297
297
  } else if (on.type === "comment") {
298
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
298
+ group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
299
299
  } else if (on.type === "pull_request") {
300
300
  // The close-only split (issue #231): a close rule routes through the close gate (the CLOSER's
301
301
  // resolved authority) and every other PR rule through the author gate, so they live in separate
@@ -304,17 +304,17 @@ function loadTriggers(env, readFile, fileExists) {
304
304
  // split reads the SHARED close-word table, never a re-typed word.
305
305
  const closeWord = PR_CLOSE_ACTIONS[run.kind];
306
306
  if (closeWord !== undefined && on.action.includes(closeWord)) {
307
- group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
307
+ group.prClose.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
308
308
  } else {
309
309
  // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
310
310
  // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
311
- group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
311
+ group.pullRequest.push({ index, actions: new Set(on.action), reviewStates: on.reviewState ? new Set(on.reviewState) : null, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
312
312
  }
313
313
  } else if (on.type === "issue") {
314
314
  // The close-trigger kind (issue #231). No predicate and no reviewStates by construction (the
315
315
  // loader refused both), and `number`/`once` ride conditionally-present exactly as the loader
316
316
  // normalized them -- the filter's close route reads them, nothing else does.
317
- group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, replicas: run.replicas });
317
+ group.issue.push({ index, actions: new Set(on.action), number: on.number, once: on.once, flow: run.flow, command: run.command, packages: run.packages, image: run.image, skillsDir: run.skillsDir, instructions: run.instructions, resume: run.resume, secrets: run.secrets, secretsProfile: run.secretsProfile, waitFor: run.waitFor, replicas: run.replicas });
318
318
  }
319
319
  }
320
320
 
@@ -119,6 +119,7 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
119
119
  // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
120
120
  ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
121
121
  ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
122
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
122
123
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
123
124
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
124
125
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
@@ -188,6 +189,7 @@ function matchLabelRules(subset, triggers, labels, action) {
188
189
  skillsDir: rule.skillsDir,
189
190
  secrets: rule.secrets,
190
191
  secretsProfile: rule.secretsProfile,
192
+ waitFor: rule.waitFor,
191
193
  instructions: rule.instructions,
192
194
  resume: rule.resume,
193
195
  replicas: rule.replicas,
@@ -231,6 +233,7 @@ function routeComment(subset, triggers, knownFlows, targetType) {
231
233
  skillsDir: triggers.comment.skillsDir,
232
234
  secrets: triggers.comment.secrets,
233
235
  secretsProfile: triggers.comment.secretsProfile,
236
+ waitFor: triggers.comment.waitFor,
234
237
  instructions: triggers.comment.instructions,
235
238
  resume: triggers.comment.resume,
236
239
  replicas: triggers.comment.replicas,
@@ -263,6 +266,7 @@ function routePullRequest(subset, triggers, action) {
263
266
  skillsDir: rule.skillsDir,
264
267
  secrets: rule.secrets,
265
268
  secretsProfile: rule.secretsProfile,
269
+ waitFor: rule.waitFor,
266
270
  instructions: rule.instructions,
267
271
  resume: rule.resume,
268
272
  replicas: rule.replicas,
@@ -133,6 +133,7 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
133
133
  // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
134
134
  ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
135
135
  ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
136
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
136
137
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
137
138
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
138
139
  // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
@@ -168,6 +169,7 @@ function routeIssueLabel(subset, triggers) {
168
169
  skillsDir: rule.skillsDir,
169
170
  secrets: rule.secrets,
170
171
  secretsProfile: rule.secretsProfile,
172
+ waitFor: rule.waitFor,
171
173
  instructions: rule.instructions,
172
174
  resume: rule.resume,
173
175
  replicas: rule.replicas,
@@ -216,6 +218,7 @@ function routeComment(subset, triggers, knownFlows) {
216
218
  skillsDir: triggers.comment.skillsDir,
217
219
  secrets: triggers.comment.secrets,
218
220
  secretsProfile: triggers.comment.secretsProfile,
221
+ waitFor: triggers.comment.waitFor,
219
222
  instructions: triggers.comment.instructions,
220
223
  resume: triggers.comment.resume,
221
224
  replicas: triggers.comment.replicas,
@@ -246,6 +249,7 @@ function routePullRequest(subset, triggers, action) {
246
249
  skillsDir: rule.skillsDir,
247
250
  secrets: rule.secrets,
248
251
  secretsProfile: rule.secretsProfile,
252
+ waitFor: rule.waitFor,
249
253
  instructions: rule.instructions,
250
254
  resume: rule.resume,
251
255
  replicas: rule.replicas,
@@ -300,6 +304,7 @@ function routeIssueClose(subset, triggers, authorized) {
300
304
  skillsDir: rule.skillsDir,
301
305
  secrets: rule.secrets,
302
306
  secretsProfile: rule.secretsProfile,
307
+ waitFor: rule.waitFor,
303
308
  instructions: rule.instructions,
304
309
  resume: rule.resume,
305
310
  replicas: rule.replicas,
@@ -335,6 +340,7 @@ function routePullRequestClose(subset, triggers, authorized) {
335
340
  skillsDir: rule.skillsDir,
336
341
  secrets: rule.secrets,
337
342
  secretsProfile: rule.secretsProfile,
343
+ waitFor: rule.waitFor,
338
344
  instructions: rule.instructions,
339
345
  resume: rule.resume,
340
346
  replicas: rule.replicas,
@@ -136,6 +136,7 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
136
136
  // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
137
137
  ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
138
138
  ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
139
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
139
140
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
140
141
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
141
142
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
@@ -178,6 +179,7 @@ function routeLabel(subset, triggers, targetType) {
178
179
  skillsDir: rule.skillsDir,
179
180
  secrets: rule.secrets,
180
181
  secretsProfile: rule.secretsProfile,
182
+ waitFor: rule.waitFor,
181
183
  instructions: rule.instructions,
182
184
  resume: rule.resume,
183
185
  replicas: rule.replicas,
@@ -241,6 +243,7 @@ function mrResult(subset, rule, matched) {
241
243
  skillsDir: rule.skillsDir,
242
244
  secrets: rule.secrets,
243
245
  secretsProfile: rule.secretsProfile,
246
+ waitFor: rule.waitFor,
244
247
  instructions: rule.instructions,
245
248
  resume: rule.resume,
246
249
  replicas: rule.replicas,
@@ -286,6 +289,7 @@ function routeIssueClose(subset, triggers, authorized) {
286
289
  skillsDir: rule.skillsDir,
287
290
  secrets: rule.secrets,
288
291
  secretsProfile: rule.secretsProfile,
292
+ waitFor: rule.waitFor,
289
293
  instructions: rule.instructions,
290
294
  resume: rule.resume,
291
295
  replicas: rule.replicas,
@@ -319,6 +323,7 @@ function routeMergeRequestClose(subset, triggers, authorized) {
319
323
  skillsDir: rule.skillsDir,
320
324
  secrets: rule.secrets,
321
325
  secretsProfile: rule.secretsProfile,
326
+ waitFor: rule.waitFor,
322
327
  instructions: rule.instructions,
323
328
  resume: rule.resume,
324
329
  replicas: rule.replicas,
@@ -370,6 +375,7 @@ function routeNote(subset, triggers, knownFlows) {
370
375
  skillsDir: triggers.comment.skillsDir,
371
376
  secrets: triggers.comment.secrets,
372
377
  secretsProfile: triggers.comment.secretsProfile,
378
+ waitFor: triggers.comment.waitFor,
373
379
  instructions: triggers.comment.instructions,
374
380
  resume: triggers.comment.resume,
375
381
  replicas: triggers.comment.replicas,
package/src/filter.mjs CHANGED
@@ -155,6 +155,7 @@ export function filter(eventName, subset, cfg, selfId, deliveryId, closerAuthori
155
155
  // receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
156
156
  ...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
157
157
  ...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
158
+ ...(resolved.waitFor !== undefined ? { waitFor: resolved.waitFor } : {}),
158
159
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
159
160
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
160
161
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
@@ -202,6 +203,7 @@ function routeIssueLabel(subset, triggers) {
202
203
  skillsDir: rule.skillsDir,
203
204
  secrets: rule.secrets,
204
205
  secretsProfile: rule.secretsProfile,
206
+ waitFor: rule.waitFor,
205
207
  instructions: rule.instructions,
206
208
  resume: rule.resume,
207
209
  replicas: rule.replicas,
@@ -261,6 +263,7 @@ function routeComment(subset, triggers, knownFlows) {
261
263
  skillsDir: triggers.comment.skillsDir,
262
264
  secrets: triggers.comment.secrets,
263
265
  secretsProfile: triggers.comment.secretsProfile,
266
+ waitFor: triggers.comment.waitFor,
264
267
  instructions: triggers.comment.instructions,
265
268
  resume: triggers.comment.resume,
266
269
  replicas: triggers.comment.replicas,
@@ -358,6 +361,7 @@ function routePullRequest(subset, triggers, action) {
358
361
  skillsDir: rule.skillsDir,
359
362
  secrets: rule.secrets,
360
363
  secretsProfile: rule.secretsProfile,
364
+ waitFor: rule.waitFor,
361
365
  instructions: rule.instructions,
362
366
  resume: rule.resume,
363
367
  replicas: rule.replicas,
@@ -473,6 +477,7 @@ function routeClose(rules, number, closerAuthorized, matchedFor, targetFor) {
473
477
  skillsDir: rule.skillsDir,
474
478
  secrets: rule.secrets,
475
479
  secretsProfile: rule.secretsProfile,
480
+ waitFor: rule.waitFor,
476
481
  instructions: rule.instructions,
477
482
  resume: rule.resume,
478
483
  replicas: rule.replicas,
package/src/receiver.mjs CHANGED
@@ -134,7 +134,11 @@ export function parseSubset(payload) {
134
134
  * undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
135
135
  * failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
136
136
  */
137
- export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority }) {
137
+ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority, router = null }) {
138
+ // Which queue a delivery is enqueued onto (issue #57, `OQ-032`). The default is the shared queue for
139
+ // EVERY job, which is what this receiver did before multi-host existed -- so a deployment that wires no
140
+ // router, and every test that constructs one without it, is byte-identical.
141
+ const routeTo = router ? (kind, job) => router.queueFor(kind, job) : () => queue;
138
142
  // Built only when the deployment serves GitHub. Construction is not free of the secret either: the
139
143
  // `new Webhooks({ secret })` inside makeVerifiedHandler throws "options.secret required" on an absent
140
144
  // one, so not building the arm is what lets a github-free deployment legitimately have no secret --
@@ -143,7 +147,7 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
143
147
  // `resolveAuthority` is the GITHUB closer resolver (issue #231), riding top-level beside `selfId`
144
148
  // because github's dependencies always have -- the other forges bundle theirs in per-forge objects.
145
149
  // Optional, because only a close delivery an armed close rule matches ever consults it.
146
- const github = cfg.servesGithub ? makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) : null;
150
+ const github = cfg.servesGithub ? makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority }) : null;
147
151
 
148
152
  // A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
149
153
  // it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
@@ -151,9 +155,9 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
151
155
  // falls through to GitHub, which is what keeps `/` working -- and a configured-off GitHub answers the
152
156
  // same 404 from the fallthrough itself, below.
153
157
  const routes = {
154
- "/gitlab": gitlab ? makeGitLabHandler({ queue, cfg, log, ...gitlab }) : null,
155
- "/forgejo": forgejo ? makeForgejoHandler({ queue, cfg, log, ...forgejo }) : null,
156
- "/azure": azure ? makeAzureHandler({ queue, cfg, log, ...azure }) : null,
158
+ "/gitlab": gitlab ? makeGitLabHandler({ queue, routeTo, cfg, log, ...gitlab }) : null,
159
+ "/forgejo": forgejo ? makeForgejoHandler({ queue, routeTo, cfg, log, ...forgejo }) : null,
160
+ "/azure": azure ? makeAzureHandler({ queue, routeTo, cfg, log, ...azure }) : null,
157
161
  };
158
162
 
159
163
  return async function receiverHandler(req, res) {
@@ -223,7 +227,7 @@ async function fanout(job, enqueue) {
223
227
  * uses, so a lookup is never spent on a delivery the gate then ignores -- every label, comment, PR and
224
228
  * review delivery, and every close nothing wants, stays payload-only and byte-identical to before.
225
229
  */
226
- function makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) {
230
+ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority }) {
227
231
  return makeVerifiedHandler({ secret: cfg.webhookSecret }, async ({ rawBody, event, delivery }, res) => {
228
232
  let subset;
229
233
  try {
@@ -264,7 +268,7 @@ function makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) {
264
268
  // Fanout (REQ-REPLICA-RUNS) lives in `fanout` above; the 202/503 decision stays here, where it always was.
265
269
  let replicas;
266
270
  try {
267
- replicas = await fanout(result.job, (j) => enqueueGitHubJob(queue, j));
271
+ replicas = await fanout(result.job, async (j) => await enqueueGitHubJob(await routeTo("github", j), j));
268
272
  } catch (err) {
269
273
  // Own try/catch so a Valkey-down enqueue is a 503 (retryable), not verify's outer 500.
270
274
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
@@ -289,7 +293,7 @@ function makeGitHubHandler({ queue, selfId, cfg, log, resolveAuthority }) {
289
293
  * The lookup runs only for events that could still fire -- after verification, and after the payload has
290
294
  * been projected -- so an unauthenticated flood cannot make this project call GitLab at all.
291
295
  */
292
- function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAuthority, now }) {
296
+ function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, resolveAuthority, now }) {
293
297
  return makeGitLabVerifiedHandler({ mode, secret, ...(now ? { now } : {}) }, async ({ rawBody, delivery }, res) => {
294
298
  let subset;
295
299
  try {
@@ -314,7 +318,7 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
314
318
 
315
319
  let replicas;
316
320
  try {
317
- replicas = await fanout(result.job, (j) => enqueueGitLabJob(queue, j));
321
+ replicas = await fanout(result.job, async (j) => await enqueueGitLabJob(await routeTo("gitlab", j), j));
318
322
  } catch (err) {
319
323
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
320
324
  return respond(res, 503, { error: "enqueue-failed" }); // GitLab redelivers; dedup by webhook-id coalesces
@@ -341,7 +345,7 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
341
345
  * per construction, and serving two sources from one would mean either sharing a secret between forges or
342
346
  * letting the request choose which one it was checked against.
343
347
  */
344
- function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority }) {
348
+ function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveAuthority }) {
345
349
  return makeVerifiedHandler({ secret }, async ({ rawBody, event, delivery }, res) => {
346
350
  let subset;
347
351
  try {
@@ -366,7 +370,7 @@ function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority
366
370
 
367
371
  let replicas;
368
372
  try {
369
- replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "forgejo", j));
373
+ replicas = await fanout(result.job, async (j) => await enqueueForgeJob(await routeTo("forgejo", j), "forgejo", j));
370
374
  } catch (err) {
371
375
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
372
376
  return respond(res, 503, { error: "enqueue-failed" }); // Forgejo redelivers; dedup by GUID coalesces
@@ -391,7 +395,7 @@ function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority
391
395
  * `"Display Name <email>"`. Both the resolver and the bot-loop guard handle both forms -- see
392
396
  * filter-azure.mjs, where the ordering constraint is stated in full.
393
397
  */
394
- function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority }) {
398
+ function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority }) {
395
399
  return makeAzureVerifiedHandler({ mode, secret, headerName }, async ({ rawBody }, res) => {
396
400
  let subset;
397
401
  try {
@@ -424,7 +428,7 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
424
428
 
425
429
  let replicas;
426
430
  try {
427
- replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "azure", j));
431
+ replicas = await fanout(result.job, async (j) => await enqueueForgeJob(await routeTo("azure", j), "azure", j));
428
432
  } catch (err) {
429
433
  log?.({ event: "enqueue_failed", delivery, reason: err?.message });
430
434
  return respond(res, 503, { error: "enqueue-failed" });
package/src/route.mjs ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Which queue a forge delivery is enqueued onto, when the deployment runs on more than one machine
3
+ * (issue #57, `OQ-032`).
4
+ *
5
+ * The receiver is the only process that can make this decision. Routing has to happen at ENQUEUE -- a
6
+ * delayed job is promoted on each worker's own clock, so the fastest clock wins every attempt and a job
7
+ * cannot reliably be handed from a host that will not serve it to one that will -- and forge deliveries are
8
+ * enqueued here. `capabilities.mjs` owns the RULE and is shared with the worker so the two cannot drift;
9
+ * this module owns the plumbing: one cached registry read, and a pool of queue handles.
10
+ *
11
+ * EVERYTHING HERE FAILS OPEN ONTO THE SHARED QUEUE, which is what this receiver did before any of it
12
+ * existed. An unreadable registry, a timed-out read, a malformed row, a name that is not routable: every
13
+ * one of them returns the shared queue, so the worst this can do is fail to improve on a coin flip. A
14
+ * webhook handler is the wrong place to invent a new way to drop work.
15
+ */
16
+
17
+ import { readLiveHosts } from "@edgehero/pi-dispatch/host-registry";
18
+ import { makeQueue, hostQueueName } from "@edgehero/pi-dispatch/queue";
19
+ import { parseConnection, makeRedisClient } from "@edgehero/pi-dispatch/connection";
20
+ import { forgeDeliveryJobId } from "@edgehero/pi-dispatch/job-id";
21
+ import { jobNeeds, routeForgeJob } from "@edgehero/pi-dispatch/capabilities";
22
+
23
+ /**
24
+ * How long a registry read is reused.
25
+ *
26
+ * The registry beats every 15s, so anything below that mostly re-reads a value that cannot have changed.
27
+ * Five seconds is well inside one beat and bounds the extra load a busy receiver puts on Valkey at one read
28
+ * per five seconds rather than one per delivery -- and a delivery burst, which is exactly when this must
29
+ * not add latency, is served entirely from cache.
30
+ *
31
+ * Staleness costs nothing that freshness would have saved: a host that appears within the window is missed
32
+ * and its work goes to the shared queue, which is today's behaviour, and a host that vanishes is already
33
+ * guarded by `ROUTE_FRESH_MS` on the row's own beat timestamp.
34
+ */
35
+ const HOSTS_TTL_MS = 5_000;
36
+
37
+ /** Bound on the registry read itself. A routing hint is never worth making a webhook wait. */
38
+ const READ_TIMEOUT_MS = 1_500;
39
+
40
+ /**
41
+ * @returns {{queueFor: (kind: string, job: object) => Promise<object>, close: () => Promise<void>}}
42
+ */
43
+ export function makeForgeRouter({
44
+ valkeyUrl,
45
+ shared,
46
+ log = () => {},
47
+ makeQueueFn = makeQueue,
48
+ parseConnectionFn = parseConnection,
49
+ redisFn = makeRedisClient,
50
+ readLiveHostsFn = readLiveHosts,
51
+ now = () => Date.now(),
52
+ ttlMs = HOSTS_TTL_MS,
53
+ } = {}) {
54
+ const pool = new Map(); // queue name -> Queue, opened lazily and only for hosts actually routed to
55
+ let redis = null;
56
+ let cached = { at: -Infinity, hosts: [] };
57
+ let inFlight = null;
58
+
59
+ // One read at a time. Without this a burst of deliveries arriving on a cold cache each start their own
60
+ // registry read, which is the moment the deployment can least afford N of them.
61
+ const hosts = async () => {
62
+ if (now() - cached.at < ttlMs) return cached.hosts;
63
+ if (inFlight) return await inFlight;
64
+ inFlight = (async () => {
65
+ try {
66
+ if (!redis) {
67
+ redis = redisFn(valkeyUrl);
68
+ // A receiver must never print ioredis reconnect noise into its own log: this client is an
69
+ // optimisation, and its failures are already handled by falling back to the shared queue.
70
+ redis.on?.("error", () => {});
71
+ }
72
+ const res = await readLiveHostsFn(redis, { timeoutMs: READ_TIMEOUT_MS });
73
+ cached = { at: now(), hosts: Array.isArray(res?.hosts) ? res.hosts : [] };
74
+ } catch {
75
+ // Cache the FAILURE too, so an unreachable Valkey costs one read per window rather than one
76
+ // per delivery. The empty list routes everything to the shared queue.
77
+ cached = { at: now(), hosts: [] };
78
+ } finally {
79
+ inFlight = null;
80
+ }
81
+ return cached.hosts;
82
+ })();
83
+ return await inFlight;
84
+ };
85
+
86
+ return {
87
+ async queueFor(kind, job) {
88
+ const needs = jobNeeds(job);
89
+ // The overwhelming majority of deliveries bind neither a secret profile nor a wait profile, and
90
+ // they must not pay even a cache lookup for a decision that cannot apply to them.
91
+ if (needs.length === 0) return shared;
92
+
93
+ let name = null;
94
+ try {
95
+ name = routeForgeJob({ hosts: await hosts(), needs, jobId: forgeDeliveryJobId(kind, job?.trigger?.deliveryId, job?.replica) });
96
+ } catch {
97
+ return shared; // an unroutable id, a bad row: the shared queue is always a correct answer
98
+ }
99
+ if (!name) return shared;
100
+
101
+ const queueName = hostQueueName(name);
102
+ if (!pool.has(queueName)) pool.set(queueName, makeQueueFn(parseConnectionFn(valkeyUrl), { name: queueName }));
103
+ // Logged because a routed job is the one case where "which host ran it" was DECIDED rather than
104
+ // observed, and an operator debugging a job that never started needs to know it was sent
105
+ // somewhere specific. Host names are deployment topology: this reaches the log, never a forge
106
+ // comment (`triggers.mjs` sets that rule for refusal messages and it holds here).
107
+ log({ event: "forge_job_routed", kind, host: name, needs: needs.join(",") });
108
+ return pool.get(queueName);
109
+ },
110
+ async close() {
111
+ for (const q of pool.values()) {
112
+ try {
113
+ await q.close();
114
+ } catch {
115
+ // best-effort teardown
116
+ }
117
+ }
118
+ pool.clear();
119
+ try {
120
+ redis?.disconnect?.();
121
+ } catch {
122
+ // best-effort teardown
123
+ }
124
+ },
125
+ };
126
+ }
package/src/start.mjs CHANGED
@@ -44,6 +44,7 @@ import { makeResolveForgejoAuthority } from "./forgejo-members.mjs";
44
44
  import { makeResolveAzureAuthority } from "./azure-members.mjs";
45
45
  import { makeResolveGitHubAuthority } from "./github-members.mjs";
46
46
  import { makeQueue } from "@edgehero/pi-dispatch/queue";
47
+ import { makeForgeRouter } from "./route.mjs";
47
48
  import { parseConnection } from "@edgehero/pi-dispatch/connection";
48
49
 
49
50
  /**
@@ -55,6 +56,7 @@ export async function startReceiver(
55
56
  {
56
57
  makeAuth = makeGitHubAuth,
57
58
  makeQueueFn = makeQueue,
59
+ makeForgeRouterFn = makeForgeRouter,
58
60
  createServer = http.createServer,
59
61
  resolveGitLabSelfId: resolveSelfIdFn = resolveGitLabSelfId,
60
62
  makeResolveAuthority: makeResolveAuthorityFn = makeResolveAuthority,
@@ -111,6 +113,15 @@ export async function startReceiver(
111
113
  // restart, not give up on a transient disconnect.
112
114
  const queue = makeQueueFn(parseConnection(cfg.valkeyUrl));
113
115
 
116
+ // Multi-host routing for deliveries that bind a host-local resource (issue #57, `OQ-032`). A trigger
117
+ // naming `run.secretsProfile` or a `run.waitFor` profile can only run where that profile is declared, and
118
+ // which worker pops a shared-queue job is a coin flip -- so the same trigger succeeded or failed by
119
+ // chance, permanently, and read like a configuration error rather than a placement one.
120
+ //
121
+ // Every failure path inside the router returns the shared queue, so a receiver whose Valkey read fails,
122
+ // or whose deployment has no named hosts, behaves exactly as it did before this existed.
123
+ const router = makeForgeRouterFn({ valkeyUrl: cfg.valkeyUrl, shared: queue, log });
124
+
114
125
  // The GitLab arm, when configured. Its identity resolution is HARD-FAIL for the same reason github's
115
126
  // is: without a selfId the bot-loop guard cannot run, and a receiver that listens without it turns the
116
127
  // harness's own status comment into another paid job.
@@ -158,7 +169,7 @@ export async function startReceiver(
158
169
  };
159
170
  }
160
171
 
161
- const handler = makeReceiver({ queue, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority });
172
+ const handler = makeReceiver({ queue, router, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority });
162
173
  const server = createServer(handler);
163
174
  server.listen(cfg.port, cfg.bind, () =>
164
175
  log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl }),
@@ -173,6 +184,7 @@ export async function startReceiver(
173
184
  log({ event: "receiver_stopping", signal });
174
185
  await new Promise((resolve) => server.close(resolve));
175
186
  await queue.close();
187
+ await router.close();
176
188
  process.exit(0);
177
189
  };
178
190
  process.once("SIGTERM", () => void shutdown("SIGTERM"));