@edgehero/pi-dispatch-receiver 4.3.1 → 5.1.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/README.md +12 -2
- package/package.json +2 -2
- package/src/azure-members.mjs +6 -3
- package/src/boot-retry.mjs +1 -1
- package/src/cli.mjs +9 -1
- package/src/config.mjs +26 -56
- package/src/filter-azure.mjs +150 -99
- package/src/filter-forgejo.mjs +148 -88
- package/src/filter-gitlab.mjs +149 -90
- package/src/filter.mjs +168 -113
- package/src/forgejo-members.mjs +4 -3
- package/src/github-members.mjs +2 -1
- package/src/gitlab-members.mjs +2 -1
- package/src/held.mjs +120 -0
- package/src/poller.mjs +62 -15
- package/src/predicate.mjs +37 -0
- package/src/receiver.mjs +290 -60
- package/src/start.mjs +26 -5
- package/src/steer.mjs +221 -0
- package/src/trigger-groups.mjs +57 -0
- package/src/verify-azure.mjs +1 -1
- package/src/verify-gitlab.mjs +5 -4
- package/src/verify-watch.mjs +40 -0
- package/src/verify.mjs +1 -1
- package/src/watch-pull.mjs +94 -0
- package/src/watch-puller.mjs +82 -0
- package/src/watch.mjs +60 -0
- package/src/http-body.mjs +0 -73
package/src/receiver.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
* session and binds no port.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
import { makeReceiverObservations } from "@edgehero/pi-dispatch/receiver-status";
|
|
23
|
+
|
|
22
24
|
import { makeVerifiedHandler } from "./verify.mjs";
|
|
23
25
|
import { filter, wantsCloserAuthority } from "./filter.mjs";
|
|
24
26
|
import { enqueueForgeJob, enqueueGitHubJob, enqueueGitLabJob } from "@edgehero/pi-dispatch/queue";
|
|
@@ -30,9 +32,18 @@ import { parseForgejoSubset } from "./forgejo-subset.mjs";
|
|
|
30
32
|
import { filterAzure } from "./filter-azure.mjs";
|
|
31
33
|
import { parseAzureSubset } from "./azure-subset.mjs";
|
|
32
34
|
import { makeAzureVerifiedHandler } from "./verify-azure.mjs";
|
|
35
|
+
import { trySteer } from "./steer.mjs";
|
|
36
|
+
import { holdAfterDedup, holdBeforeLabel, wantsHoldAfterDedup } from "./held.mjs";
|
|
37
|
+
import { makeWatchHandler } from "./watch.mjs";
|
|
38
|
+
import { makeWatchStore } from "@edgehero/pi-dispatch/watch-store";
|
|
39
|
+
import { enqueueWatchJob } from "@edgehero/pi-dispatch/queue";
|
|
33
40
|
|
|
34
41
|
/** Write a small JSON body with an explicit status; a 204 carries no body. verify's `respond` is private. */
|
|
42
|
+
const responseResults = new WeakMap();
|
|
35
43
|
function respond(res, status, obj) {
|
|
44
|
+
// Only our own closed response words disambiguate 202 and 503; no request body is inspected.
|
|
45
|
+
if (status === 202) responseResults.set(res, obj?.status);
|
|
46
|
+
if (status === 503) responseResults.set(res, obj?.error === "enqueue-failed" ? "enqueue-failed" : "lookup-failed");
|
|
36
47
|
if (status === 204) {
|
|
37
48
|
res.writeHead(204);
|
|
38
49
|
res.end();
|
|
@@ -134,7 +145,14 @@ export function parseSubset(payload) {
|
|
|
134
145
|
* undefined, i.e. never drops the harness's own comments. Absent property therefore means no route; the
|
|
135
146
|
* failure of a forgotten property is a 404 an operator sees, never a paid recursion they get billed for.
|
|
136
147
|
*/
|
|
137
|
-
export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority, router = null }) {
|
|
148
|
+
export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo = null, azure = null, resolveAuthority, router = null, steerRedis, now = Date.now, watchEnv = process.env, watchStore = null, enqueueWatch = enqueueWatchJob, observations = makeReceiverObservations({ now }) }) {
|
|
149
|
+
const watch = makeWatchHandler({ cfg, env: watchEnv, now, log: (event, fields) => log?.({ event, ...fields }), store: watchStore ?? Object.fromEntries(["ingest", "appendTrace", "recordVerdict", "noteIssueActivity"].map(method => [method, async (...args) => makeWatchStore(await queue.client, { now })[method](...args)])), enqueue: fields => enqueueWatch(queue, fields, { now }) });
|
|
150
|
+
// The Valkey client comment steering (issue #646) reads the live runs with and pushes into: by default the shared
|
|
151
|
+
// queue's own connection (`queue.client`, BullMQ's promise of its ioredis client), so steering opens no second
|
|
152
|
+
// connection and has no lifecycle of its own to close. Asked only for a job that carries `steerMode`, so a
|
|
153
|
+
// deployment with no steer-armed comment trigger never touches it. `steerRedis` is the seam.
|
|
154
|
+
const steerClient = typeof steerRedis === "function" ? steerRedis : () => queue?.client;
|
|
155
|
+
const steer = { steerClient, now, log };
|
|
138
156
|
// Which queue a delivery is enqueued onto (issue #57, `OQ-032`). The default is the shared queue for
|
|
139
157
|
// EVERY job, which is what this receiver did before multi-host existed -- so a deployment that wires no
|
|
140
158
|
// router, and every test that constructs one without it, is byte-identical.
|
|
@@ -147,7 +165,7 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
|
|
|
147
165
|
// `resolveAuthority` is the GITHUB closer resolver (issue #231), riding top-level beside `selfId`
|
|
148
166
|
// because github's dependencies always have -- the other forges bundle theirs in per-forge objects.
|
|
149
167
|
// Optional, because only a close delivery an armed close rule matches ever consults it.
|
|
150
|
-
const github = cfg.servesGithub ? makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority }) : null;
|
|
168
|
+
const github = cfg.servesGithub ? makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority, steer }) : null;
|
|
151
169
|
|
|
152
170
|
// A TABLE, built once, rather than one `if` per forge. Two forges made that a single branch; four make
|
|
153
171
|
// it a chain, and a chain is where one arm quietly ends up checked after the fallthrough. A path present
|
|
@@ -155,13 +173,14 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
|
|
|
155
173
|
// falls through to GitHub, which is what keeps `/` working -- and a configured-off GitHub answers the
|
|
156
174
|
// same 404 from the fallthrough itself, below.
|
|
157
175
|
const routes = {
|
|
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,
|
|
176
|
+
"/gitlab": gitlab ? makeGitLabHandler({ queue, routeTo, cfg, log, ...gitlab, steer }) : null,
|
|
177
|
+
"/forgejo": forgejo ? makeForgejoHandler({ queue, routeTo, cfg, log, ...forgejo, steer }) : null,
|
|
178
|
+
"/azure": azure ? makeAzureHandler({ queue, routeTo, cfg, log, ...azure, steer }) : null,
|
|
161
179
|
};
|
|
162
180
|
|
|
163
|
-
|
|
181
|
+
const route = async (req, res) => {
|
|
164
182
|
const path = pathOf(req.url);
|
|
183
|
+
if (path === "/watch" || path.startsWith("/watch/")) return watch(req, res);
|
|
165
184
|
if (Object.hasOwn(routes, path)) {
|
|
166
185
|
const handler = routes[path];
|
|
167
186
|
// Not a 401. An endpoint that answers "unauthorized" for a forge nobody configured is an endpoint
|
|
@@ -177,6 +196,24 @@ export function makeReceiver({ queue, selfId, cfg, log, gitlab = null, forgejo =
|
|
|
177
196
|
if (!github) return respond(res, 404, { error: "github webhooks are not configured" });
|
|
178
197
|
return await github(req, res);
|
|
179
198
|
};
|
|
199
|
+
const receiverHandler = async (req, res) => {
|
|
200
|
+
const path = pathOf(req.url);
|
|
201
|
+
if (path === "/watch" || path.startsWith("/watch/")) return route(req, res);
|
|
202
|
+
const forge = ({ "/gitlab": "gitlab", "/forgejo": "forgejo", "/azure": "azure" })[path] ?? "github";
|
|
203
|
+
const end = res.end;
|
|
204
|
+
let counted = false;
|
|
205
|
+
res.end = function (...args) {
|
|
206
|
+
const result = end.apply(this, args);
|
|
207
|
+
if (!counted) {
|
|
208
|
+
counted = true;
|
|
209
|
+
try { observations.observe(forge, res.statusCode, responseResults.get(res)); } catch {}
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
};
|
|
213
|
+
try { return await route(req, res); }
|
|
214
|
+
finally { res.end = end; responseResults.delete(res); }
|
|
215
|
+
};
|
|
216
|
+
return Object.assign(receiverHandler, { observations });
|
|
180
217
|
}
|
|
181
218
|
|
|
182
219
|
/** The path portion of a request URL, without query or trailing slash. Never throws on a malformed URL. */
|
|
@@ -198,9 +235,17 @@ function pathOf(url) {
|
|
|
198
235
|
* Absent `replicas` is `1` and the call below is byte-identical to the single enqueue it replaced: no
|
|
199
236
|
* `replica` key on the job, so the jobId, the dedup id and `data` are exactly what they were.
|
|
200
237
|
*
|
|
201
|
-
* PARTIAL FAILURE
|
|
202
|
-
*
|
|
203
|
-
*
|
|
238
|
+
* A PARTIAL FAILURE LEAVES REPLICAS 1..k-1 QUEUED, and nothing here takes them back. If replica k throws,
|
|
239
|
+
* the caller answers 503 (`answerEnqueueFailed`), and what happens next is the forge's (WHAT A 503 MEANS,
|
|
240
|
+
* below): on GitHub, GitLab and Forgejo nothing, so k-1 of the n jobs run and the rest never queue unless
|
|
241
|
+
* someone sends the delivery again. A GitHub or GitLab redelivery by hand, or Azure's own resend, keeps the
|
|
242
|
+
* delivery id, so replicas 1..k-1 dedup on their taken jobIds, k..n enqueue, and it converges on exactly n.
|
|
243
|
+
* A Forgejo replay is a NEW delivery id: inside the 10-minute semantic window the queued replicas coalesce
|
|
244
|
+
* on their `:r<i>` keys and it still converges on n, but after it all n queue again beside the k-1, so
|
|
245
|
+
* cancel those first. Compensating here was rejected: removing 1..k-1 needs the Valkey that just failed,
|
|
246
|
+
* and one of them may already be running. So the failure says how far it got instead: past one replica,
|
|
247
|
+
* the error that goes up carries `queued` (k-1, created or swallowed by the window) and `replicas` (n) for
|
|
248
|
+
* the `enqueue_failed` line. A single enqueue's error goes up untouched, so its line is byte-identical.
|
|
204
249
|
*
|
|
205
250
|
* `enqueue` is a callback because the four arms spell their enqueue differently (a named github/gitlab
|
|
206
251
|
* wrapper, or `enqueueForgeJob` with an explicit kind); the fanout itself is forge-blind.
|
|
@@ -216,24 +261,103 @@ async function fanout(job, enqueue) {
|
|
|
216
261
|
let created = 0;
|
|
217
262
|
const deduplicated = [];
|
|
218
263
|
for (let i = 1; i <= replicas; i++) {
|
|
219
|
-
|
|
264
|
+
let r;
|
|
265
|
+
try {
|
|
266
|
+
r = await enqueue(replicas > 1 ? { ...job, replica: i } : job);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
if (replicas === 1) throw err;
|
|
269
|
+
throw Object.assign(new Error(err?.message, { cause: err }), { queued: i - 1, replicas });
|
|
270
|
+
}
|
|
220
271
|
if (r && typeof r === "object" && r.deduplicated === true) deduplicated.push({ jobId: r.jobId, survivingJobId: r.survivingJobId });
|
|
221
272
|
else created += 1;
|
|
222
273
|
}
|
|
223
274
|
return { replicas, created, deduplicated };
|
|
224
275
|
}
|
|
225
276
|
|
|
277
|
+
/**
|
|
278
|
+
* WHAT A 503 MEANS FOR EACH FORGE. Every arm answers 503 when it cannot finish a delivery it would otherwise
|
|
279
|
+
* queue: an INDETERMINATE author lookup (and the GitHub arm's unwired resolver), or an enqueue that throws.
|
|
280
|
+
* The delivery is not queued, and the answer is kept because it is the true one: a 204 would read as a
|
|
281
|
+
* stranger correctly refused, and a 202 would say "queued". What happens to the delivery is the forge's:
|
|
282
|
+
* - GitHub never resends a failed delivery on its own (docs.github.com, "Handling failed webhook
|
|
283
|
+
* deliveries"). It is lost unless someone redelivers it from the hook's Recent Deliveries or the REST
|
|
284
|
+
* API within 3 days; that keeps `X-GitHub-Delivery`, so it queues at most once.
|
|
285
|
+
* - GitLab never resends a 5xx or a timeout either, and after 4 failures in a row it disables the hook
|
|
286
|
+
* for a while (on gitlab.com, and a group hook anywhere; on a self-managed instance a project hook only
|
|
287
|
+
* where the `auto_disabling_web_hooks` feature flag is on). A "Resend Request" (Recent events, 2 days;
|
|
288
|
+
* the API's resend since 17.4) keeps the `Idempotency-Key` and is signed afresh, so it queues at most
|
|
289
|
+
* once and the replay window holds.
|
|
290
|
+
* - Forgejo makes one attempt (`services/webhook/deliver.go`). Its replay, from the UI, is a NEW hook task
|
|
291
|
+
* with a NEW delivery id: inside the 10-minute semantic window it coalesces with what did queue, after
|
|
292
|
+
* the window it runs again, which matters for a delivery Forgejo saw fail only because the answer came
|
|
293
|
+
* after its 5 s `DELIVER_TIMEOUT`, when the job had in fact queued.
|
|
294
|
+
* - Azure DevOps treats a 503 as transient and resends the notification up to 8 times over about 3
|
|
295
|
+
* minutes. It resends the same notification, so it carries the same payload `id` and queues at most
|
|
296
|
+
* once. Past that the subscription goes on probation: Azure retries the failed notification up to 7
|
|
297
|
+
* more times over about 36 hours, and new events are lost meanwhile. So a delivery someone re-triggers
|
|
298
|
+
* by hand in that time (a new event, a new `id`) can run twice: a probation retry that lands outside the
|
|
299
|
+
* 10-minute semantic window is not coalesced with it. A 500 is an enduring failure at once.
|
|
300
|
+
* The receiver logs each 503 with the delivery id an operator redelivers by, and the Connections page counts
|
|
301
|
+
* `lookup-failed` and `enqueue-failed` per forge and warns when either is above 0.
|
|
302
|
+
*
|
|
303
|
+
* This function is the enqueue-failure answer, shared by the four arms for the four-copies reason `fanout`
|
|
304
|
+
* gives. Their own try/catch exists so a Valkey-down enqueue is this 503 and not verify's outer 500, which
|
|
305
|
+
* Azure would count as an enduring failure. The `enqueue_failed` line names the delivery and the reason; past
|
|
306
|
+
* one replica it adds how many of the `replicas` were `queued` before the failure (`fanout`), which is what an
|
|
307
|
+
* operator needs before redelivering, and on Forgejo before replaying.
|
|
308
|
+
*/
|
|
309
|
+
function answerEnqueueFailed({ log, res, delivery, err }) {
|
|
310
|
+
// Past one replica `err` is `fanout`'s wrapper, and the reason is its cause's: a thrown non-Error has no
|
|
311
|
+
// `message`, so its line keeps leaving `reason` out instead of logging the wrapper's empty one.
|
|
312
|
+
const fanned = err?.replicas > 1;
|
|
313
|
+
const partial = fanned ? { queued: err.queued, replicas: err.replicas } : {};
|
|
314
|
+
log?.({ event: "enqueue_failed", delivery, reason: (fanned ? err.cause : err)?.message, ...partial });
|
|
315
|
+
return respond(res, 503, { error: "enqueue-failed" });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Comment steering (issue #646), shared by all four arms for the four-copies reason `fanout` gives. Runs after the gate
|
|
320
|
+
* and before any enqueue, and only for a job the filter marked `steerMode` (a phrase comment from an admitted author on
|
|
321
|
+
* a steer-armed trigger): every other delivery passes through untouched, so its job is byte-identical to before.
|
|
322
|
+
*
|
|
323
|
+
* Returns `{ handled: true }` when a live run took the comment, after answering `202 {status:"steered", runs}`; else
|
|
324
|
+
* `{ job }`, the job to enqueue exactly as today, with `steerMode` removed (`enqueueForgeJob` names its fields and
|
|
325
|
+
* would drop it anyway; removing it here keeps it off every seam a test or a router sees). The `steered` line names the
|
|
326
|
+
* delivery, the target in the arm's own grammar, the mode, and the runs, never the comment's text.
|
|
327
|
+
*/
|
|
328
|
+
async function steerOrEnqueue({ steer, res, kind, delivery, job, target }) {
|
|
329
|
+
if (job.steerMode === undefined) return { job };
|
|
330
|
+
const { steerMode: mode, ...rest } = job;
|
|
331
|
+
let redis = null;
|
|
332
|
+
try {
|
|
333
|
+
redis = (await steer.steerClient()) ?? null;
|
|
334
|
+
} catch {
|
|
335
|
+
redis = null; // trySteer logs `steer_lookup_failed` for a missing client and the comment is enqueued
|
|
336
|
+
}
|
|
337
|
+
const steered = await trySteer({ redis, job, kind, now: steer.now, log: (line) => steer.log?.(line) });
|
|
338
|
+
if (steered === null) return { job: rest };
|
|
339
|
+
// A redelivery of a delivery steered earlier (its run has ended since): answered, never enqueued. trySteer logged it.
|
|
340
|
+
if (steered.redelivered === true) {
|
|
341
|
+
respond(res, 202, { status: "steered", runs: 0 });
|
|
342
|
+
return { handled: true };
|
|
343
|
+
}
|
|
344
|
+
steer.log?.({ event: "steered", delivery, repo: job.repo, target, mode, runs: steered.steered, runIds: steered.runIds });
|
|
345
|
+
respond(res, 202, { status: "steered", runs: steered.steered });
|
|
346
|
+
return { handled: true };
|
|
347
|
+
}
|
|
348
|
+
|
|
226
349
|
/**
|
|
227
350
|
* One log-and-answer for a fanout outcome, shared by all four arms so a weakened copy cannot hide (the
|
|
228
351
|
* four-copies doctrine above). Issue #289's honesty rule: `enqueued` is logged only when something was
|
|
229
352
|
* CREATED, with `replicas` meaning jobs that now exist; every semantic-window swallow gets its own
|
|
230
353
|
* `deduplicated` line naming the surviving job's id (every field forge- or worker-minted, no payload
|
|
231
354
|
* text); and a delivery that created NOTHING answers `202 {status:"deduplicated"}` -- still a 2xx,
|
|
232
|
-
* because a non-2xx would
|
|
355
|
+
* because a non-2xx would record a delivery that was handled as failed in the forge's own history, count
|
|
356
|
+
* toward GitLab disabling the hook, and make Azure resend it, but no longer
|
|
233
357
|
* "queued", because a receiver that logs the swallow while answering success on the wire would be the
|
|
234
358
|
* honest-log/lying-wire split this project refuses. Each arm passes its own target grammar.
|
|
235
359
|
*/
|
|
236
|
-
function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome }) {
|
|
360
|
+
function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome, held = null }) {
|
|
237
361
|
for (const d of outcome.deduplicated) {
|
|
238
362
|
log?.({ event: "deduplicated", delivery, repo, target, flow, jobId: d.jobId, survivingJobId: d.survivingJobId });
|
|
239
363
|
}
|
|
@@ -241,9 +365,54 @@ function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome
|
|
|
241
365
|
log?.({ event: "enqueued", delivery, repo, target, flow, replicas: outcome.created });
|
|
242
366
|
return respond(res, 202, { status: "queued" });
|
|
243
367
|
}
|
|
368
|
+
// Issue #638: a swallow a parking run took as its held answer is not "nothing happened" either; the `held` lines
|
|
369
|
+
// were written by held.mjs, and the wire says so the same honest way.
|
|
370
|
+
if (held !== null) return respond(res, 202, { status: "held", runs: held.held });
|
|
244
371
|
return respond(res, 202, { status: "deduplicated" });
|
|
245
372
|
}
|
|
246
373
|
|
|
374
|
+
/** The Valkey client held replies use: the steer one (`steer.steerClient()`), null when there is none. */
|
|
375
|
+
async function heldClientOf(steer) {
|
|
376
|
+
try {
|
|
377
|
+
return (await steer?.steerClient?.()) ?? null;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The fanout outcome, answered, with held replies (issue #638) in between, shared by the four arms. Only a delivery
|
|
385
|
+
* `wantsHoldAfterDedup` names (a resume comment of an awaitInput trigger that the window swallowed) asks Valkey at all,
|
|
386
|
+
* so every other delivery's log lines and answer are exactly `respondEnqueueOutcome`'s as they were.
|
|
387
|
+
*/
|
|
388
|
+
async function answerEnqueueOutcome({ steer, kind, log, res, delivery, job, target, outcome }) {
|
|
389
|
+
let held = null;
|
|
390
|
+
if (wantsHoldAfterDedup(job, outcome)) {
|
|
391
|
+
held = await holdAfterDedup({ redis: await heldClientOf(steer), job, kind, outcome, now: steer?.now, log: (line) => log?.(line), delivery, target });
|
|
392
|
+
}
|
|
393
|
+
return respondEnqueueOutcome({ log, res, delivery, repo: job.repo, target, flow: job.flow, outcome, held });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The two target grammars the arms log in: `type#n` (github, forgejo) and `repo#n` / `repo!n` (gitlab, azure). */
|
|
397
|
+
const hashTarget = (j) => `${j?.target?.type}#${j?.target?.number}`;
|
|
398
|
+
const sepTarget = (j) => `${j?.repo}${j?.target?.type === "pull_request" ? "!" : "#"}${j?.target?.number}`;
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A filter drop that carries `heldCandidate` (issue #638): held under every awaitInput run live on its target, then
|
|
402
|
+
* answered `202 {status:"held", runs}` and "held". "enqueue" when a run parked on the target within the last minute
|
|
403
|
+
* (held.mjs): the caller enqueues the candidate as the parked reply it is. Null for every other drop, and for a
|
|
404
|
+
* candidate nothing took, and the caller answers its drop exactly as before. A missing client is today's behaviour.
|
|
405
|
+
*/
|
|
406
|
+
async function heldBeforeLabelOr({ steer, res, kind, delivery, result, targetOf }) {
|
|
407
|
+
if (result?.heldCandidate === undefined) return null;
|
|
408
|
+
const candidate = result.heldCandidate;
|
|
409
|
+
const held = await holdBeforeLabel({ redis: await heldClientOf(steer), candidate, kind, now: steer?.now, log: (line) => steer?.log?.(line), delivery, target: targetOf(candidate) });
|
|
410
|
+
if (held === null) return null;
|
|
411
|
+
if (held.enqueue === true) return "enqueue";
|
|
412
|
+
respond(res, 202, { status: "held", runs: held.held });
|
|
413
|
+
return "held";
|
|
414
|
+
}
|
|
415
|
+
|
|
247
416
|
/**
|
|
248
417
|
* The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
|
|
249
418
|
* request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
|
|
@@ -256,7 +425,7 @@ function respondEnqueueOutcome({ log, res, delivery, repo, target, flow, outcome
|
|
|
256
425
|
* uses, so a lookup is never spent on a delivery the gate then ignores -- every label, comment, PR and
|
|
257
426
|
* review delivery, and every close nothing wants, stays payload-only and byte-identical to before.
|
|
258
427
|
*/
|
|
259
|
-
function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority }) {
|
|
428
|
+
function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority, steer }) {
|
|
260
429
|
return makeVerifiedHandler({ secret: cfg.webhookSecret }, async ({ rawBody, event, delivery }, res) => {
|
|
261
430
|
let subset;
|
|
262
431
|
try {
|
|
@@ -270,7 +439,8 @@ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority
|
|
|
270
439
|
if (!resolveAuthority) {
|
|
271
440
|
// Defensive only: unreachable in a wired receiver -- start.mjs hard-fails on github auth
|
|
272
441
|
// before `/` is ever mounted, and the resolver is built over that same auth object. Fail
|
|
273
|
-
// closed
|
|
442
|
+
// closed with a 503, a failed delivery in the hook's history that can be redelivered once
|
|
443
|
+
// the wiring is fixed, because a wiring fault must not read on the wire as a stranger
|
|
274
444
|
// being correctly refused.
|
|
275
445
|
return respond(res, 503, { error: "permission-lookup-failed" });
|
|
276
446
|
}
|
|
@@ -279,32 +449,45 @@ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority
|
|
|
279
449
|
// The reason names the lookup, never the actor -- `sender.login` is personal data and exists
|
|
280
450
|
// here only to have been asked about (no-pii-in-logs).
|
|
281
451
|
log?.({ event: "github_permission_lookup_failed", delivery, reason: resolved.indeterminate });
|
|
282
|
-
// 503
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
// poller
|
|
452
|
+
// 503, and GitHub never resends it on its own (WHAT A 503 MEANS, above `answerEnqueueFailed`):
|
|
453
|
+
// the close is lost unless someone redelivers it within 3 days, which keeps the GUID jobId and
|
|
454
|
+
// so queues it at most once. The polling transport's closed source is the backstop on a repo
|
|
455
|
+
// the poller already polls, with its own bounded retry against the same lookup; a
|
|
456
|
+
// webhook-only deployment has none.
|
|
286
457
|
return respond(res, 503, { error: "permission-lookup-failed" });
|
|
287
458
|
}
|
|
288
459
|
closerAuthorized = resolved.authorized;
|
|
289
460
|
}
|
|
290
461
|
|
|
291
|
-
|
|
462
|
+
let result = filter(event, subset, cfg, selfId, delivery, closerAuthorized);
|
|
292
463
|
if (!result.enqueue) {
|
|
293
|
-
|
|
294
|
-
|
|
464
|
+
// Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
|
|
465
|
+
// enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
|
|
466
|
+
const rescued = await heldBeforeLabelOr({ steer, res, kind: "github", delivery, result, targetOf: hashTarget });
|
|
467
|
+
if (rescued === "held") return;
|
|
468
|
+
if (rescued === "enqueue") {
|
|
469
|
+
result = { enqueue: true, job: result.heldCandidate };
|
|
470
|
+
} else {
|
|
471
|
+
log?.({ event: "dropped", delivery, reason: result.reason });
|
|
472
|
+
return respond(res, 204);
|
|
473
|
+
}
|
|
295
474
|
}
|
|
296
475
|
|
|
476
|
+
const target = `${result.job.target.type}#${result.job.target.number}`;
|
|
477
|
+
const steered = await steerOrEnqueue({ steer, res, kind: "github", delivery, job: result.job, target });
|
|
478
|
+
if (steered.handled) return;
|
|
479
|
+
const job = steered.job;
|
|
480
|
+
|
|
297
481
|
// Fanout (REQ-REPLICA-RUNS) lives in `fanout` above; the 202/503 decision stays here, where it always was.
|
|
298
482
|
let outcome;
|
|
299
483
|
try {
|
|
300
|
-
outcome = await fanout(
|
|
484
|
+
outcome = await fanout(job, async (j) => await enqueueGitHubJob(await routeTo("github", j), j));
|
|
301
485
|
} catch (err) {
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
return respond(res, 503, { error: "enqueue-failed" }); // GitHub redelivers; dedup by GUID coalesces
|
|
486
|
+
// Lost unless redelivered by hand within 3 days; the redelivery keeps the GUID (WHAT A 503 MEANS).
|
|
487
|
+
return answerEnqueueFailed({ log, res, delivery, err });
|
|
305
488
|
}
|
|
306
489
|
|
|
307
|
-
return
|
|
490
|
+
return await answerEnqueueOutcome({ steer, kind: "github", log, res, delivery, job, target, outcome });
|
|
308
491
|
});
|
|
309
492
|
}
|
|
310
493
|
|
|
@@ -315,13 +498,15 @@ function makeGitHubHandler({ queue, routeTo, selfId, cfg, log, resolveAuthority
|
|
|
315
498
|
*
|
|
316
499
|
* That resolution is a network call, and its three outcomes are deliberately not two:
|
|
317
500
|
* - a determinate verdict (authorized or not) is handed to the gate, which decides;
|
|
318
|
-
* - an INDETERMINATE lookup is a 503
|
|
319
|
-
*
|
|
320
|
-
*
|
|
501
|
+
* - an INDETERMINATE lookup is a 503. GitLab never resends it on its own (WHAT A 503 MEANS, above
|
|
502
|
+
* `answerEnqueueFailed`), so the delivery waits for a "Resend Request", which keeps the `webhook-id`
|
|
503
|
+
* and so queues at most once. Answering 204 would drop real work during an outage behind a success
|
|
504
|
+
* nobody would think to resend, and it would look on the wire exactly like a stranger being correctly
|
|
505
|
+
* refused.
|
|
321
506
|
* The lookup runs only for events that could still fire -- after verification, and after the payload has
|
|
322
507
|
* been projected -- so an unauthenticated flood cannot make this project call GitLab at all.
|
|
323
508
|
*/
|
|
324
|
-
function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, resolveAuthority, now }) {
|
|
509
|
+
function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, resolveAuthority, now, steer }) {
|
|
325
510
|
return makeGitLabVerifiedHandler({ mode, secret, ...(now ? { now } : {}) }, async ({ rawBody, delivery }, res) => {
|
|
326
511
|
let subset;
|
|
327
512
|
try {
|
|
@@ -335,27 +520,41 @@ function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, res
|
|
|
335
520
|
// The reason names the lookup, never the actor -- `user.username` is personal data and exists
|
|
336
521
|
// only to have been asked about (no-pii-in-logs).
|
|
337
522
|
log?.({ event: "gitlab_access_lookup_failed", delivery, reason: resolved.indeterminate });
|
|
523
|
+
// Lost unless resent from Recent events within 2 days (WHAT A 503 MEANS): GitLab never resends.
|
|
338
524
|
return respond(res, 503, { error: "access-lookup-failed" });
|
|
339
525
|
}
|
|
340
526
|
|
|
341
|
-
|
|
527
|
+
let result = filterGitLab(subset, cfg.triggers?.gitlab, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
|
|
342
528
|
if (!result.enqueue) {
|
|
343
|
-
|
|
344
|
-
|
|
529
|
+
// Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
|
|
530
|
+
// enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
|
|
531
|
+
const rescued = await heldBeforeLabelOr({ steer, res, kind: "gitlab", delivery, result, targetOf: sepTarget });
|
|
532
|
+
if (rescued === "held") return;
|
|
533
|
+
if (rescued === "enqueue") {
|
|
534
|
+
result = { enqueue: true, job: result.heldCandidate };
|
|
535
|
+
} else {
|
|
536
|
+
log?.({ event: "dropped", delivery, reason: result.reason });
|
|
537
|
+
return respond(res, 204);
|
|
538
|
+
}
|
|
345
539
|
}
|
|
346
540
|
|
|
541
|
+
// `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
|
|
542
|
+
// the semantic dedup key makes, because the two are separate number sequences.
|
|
543
|
+
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
544
|
+
const target = `${result.job.repo}${sep}${result.job.target.number}`;
|
|
545
|
+
const steered = await steerOrEnqueue({ steer, res, kind: "gitlab", delivery, job: result.job, target });
|
|
546
|
+
if (steered.handled) return;
|
|
547
|
+
const job = steered.job;
|
|
548
|
+
|
|
347
549
|
let outcome;
|
|
348
550
|
try {
|
|
349
|
-
outcome = await fanout(
|
|
551
|
+
outcome = await fanout(job, async (j) => await enqueueGitLabJob(await routeTo("gitlab", j), j));
|
|
350
552
|
} catch (err) {
|
|
351
|
-
|
|
352
|
-
return
|
|
553
|
+
// Lost unless resent by hand within 2 days; the resend keeps the webhook-id (WHAT A 503 MEANS).
|
|
554
|
+
return answerEnqueueFailed({ log, res, delivery, err });
|
|
353
555
|
}
|
|
354
556
|
|
|
355
|
-
|
|
356
|
-
// the semantic dedup key makes, because the two are separate number sequences.
|
|
357
|
-
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
358
|
-
return respondEnqueueOutcome({ log, res, delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, outcome });
|
|
557
|
+
return await answerEnqueueOutcome({ steer, kind: "gitlab", log, res, delivery, job, target, outcome });
|
|
359
558
|
});
|
|
360
559
|
}
|
|
361
560
|
|
|
@@ -367,12 +566,13 @@ function makeGitLabHandler({ routeTo, queue, cfg, log, mode, secret, selfId, res
|
|
|
367
566
|
* Forgejo signs the raw body with HMAC-SHA256 and sends `X-Hub-Signature-256`, `X-GitHub-Delivery` and
|
|
368
567
|
* `X-GitHub-Event`, which is byte-for-byte what `makeVerifiedHandler` already reads. So
|
|
369
568
|
* CONST-HMAC-OVER-RAW-BODY is satisfied here by EXISTING code rather than new code, and
|
|
370
|
-
* REQ-DEDUP-BY-DELIVERY-GUID transfers with no adaptation at all
|
|
569
|
+
* REQ-DEDUP-BY-DELIVERY-GUID's mechanism transfers with no adaptation at all (what it cannot see is a replay,
|
|
570
|
+
* which Forgejo sends under a new GUID: WHAT A 503 MEANS). What Forgejo needs is its own SECRET,
|
|
371
571
|
* which is why it is a separate handler and a separate path: `makeVerifiedHandler` closes over one secret
|
|
372
572
|
* per construction, and serving two sources from one would mean either sharing a secret between forges or
|
|
373
573
|
* letting the request choose which one it was checked against.
|
|
374
574
|
*/
|
|
375
|
-
function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveAuthority }) {
|
|
575
|
+
function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveAuthority, steer }) {
|
|
376
576
|
return makeVerifiedHandler({ secret }, async ({ rawBody, event, delivery }, res) => {
|
|
377
577
|
let subset;
|
|
378
578
|
try {
|
|
@@ -386,24 +586,38 @@ function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveA
|
|
|
386
586
|
// The reason names the lookup, never the actor -- `sender.login` is personal data and exists here
|
|
387
587
|
// only to have been asked about (no-pii-in-logs).
|
|
388
588
|
log?.({ event: "forgejo_permission_lookup_failed", delivery, reason: resolved.indeterminate });
|
|
589
|
+
// Lost unless replayed from the hook's history (WHAT A 503 MEANS): Forgejo makes one attempt.
|
|
389
590
|
return respond(res, 503, { error: "permission-lookup-failed" });
|
|
390
591
|
}
|
|
391
592
|
|
|
392
|
-
|
|
593
|
+
let result = filterForgejo(event, subset, cfg.triggers?.forgejo, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
|
|
393
594
|
if (!result.enqueue) {
|
|
394
|
-
|
|
395
|
-
|
|
595
|
+
// Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
|
|
596
|
+
// enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
|
|
597
|
+
const rescued = await heldBeforeLabelOr({ steer, res, kind: "forgejo", delivery, result, targetOf: hashTarget });
|
|
598
|
+
if (rescued === "held") return;
|
|
599
|
+
if (rescued === "enqueue") {
|
|
600
|
+
result = { enqueue: true, job: result.heldCandidate };
|
|
601
|
+
} else {
|
|
602
|
+
log?.({ event: "dropped", delivery, reason: result.reason });
|
|
603
|
+
return respond(res, 204);
|
|
604
|
+
}
|
|
396
605
|
}
|
|
397
606
|
|
|
607
|
+
const target = `${result.job.target.type}#${result.job.target.number}`;
|
|
608
|
+
const steered = await steerOrEnqueue({ steer, res, kind: "forgejo", delivery, job: result.job, target });
|
|
609
|
+
if (steered.handled) return;
|
|
610
|
+
const job = steered.job;
|
|
611
|
+
|
|
398
612
|
let outcome;
|
|
399
613
|
try {
|
|
400
|
-
outcome = await fanout(
|
|
614
|
+
outcome = await fanout(job, async (j) => await enqueueForgeJob(await routeTo("forgejo", j), "forgejo", j));
|
|
401
615
|
} catch (err) {
|
|
402
|
-
|
|
403
|
-
return
|
|
616
|
+
// Lost unless replayed by hand, and a replay is a new GUID: only the semantic window coalesces it.
|
|
617
|
+
return answerEnqueueFailed({ log, res, delivery, err });
|
|
404
618
|
}
|
|
405
619
|
|
|
406
|
-
return
|
|
620
|
+
return await answerEnqueueOutcome({ steer, kind: "forgejo", log, res, delivery, job, target, outcome });
|
|
407
621
|
});
|
|
408
622
|
}
|
|
409
623
|
|
|
@@ -421,7 +635,7 @@ function makeForgejoHandler({ routeTo, queue, cfg, log, secret, selfId, resolveA
|
|
|
421
635
|
* `"Display Name <email>"`. Both the resolver and the bot-loop guard handle both forms -- see
|
|
422
636
|
* filter-azure.mjs, where the ordering constraint is stated in full.
|
|
423
637
|
*/
|
|
424
|
-
function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority }) {
|
|
638
|
+
function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName, selfId, resolveAuthority, steer }) {
|
|
425
639
|
return makeAzureVerifiedHandler({ mode, secret, headerName }, async ({ rawBody }, res) => {
|
|
426
640
|
let subset;
|
|
427
641
|
try {
|
|
@@ -443,26 +657,42 @@ function makeAzureHandler({ routeTo, queue, cfg, log, mode, secret, headerName,
|
|
|
443
657
|
// The reason names the lookup, never the actor -- an email address is personal data and exists
|
|
444
658
|
// here only to have been asked about (no-pii-in-logs).
|
|
445
659
|
log?.({ event: "azure_membership_lookup_failed", delivery, reason: resolved.indeterminate });
|
|
660
|
+
// Azure resends the same notification up to 8 times over about 3 minutes, so with the same `id`,
|
|
661
|
+
// then retries it on probation for about 36 hours (WHAT A 503 MEANS).
|
|
446
662
|
return respond(res, 503, { error: "membership-lookup-failed" });
|
|
447
663
|
}
|
|
448
664
|
|
|
449
|
-
|
|
665
|
+
let result = filterAzure(subset, cfg.triggers?.azure, cfg.triggers?.knownFlows, selfId, resolved.authorized, delivery);
|
|
450
666
|
if (!result.enqueue) {
|
|
451
|
-
|
|
452
|
-
|
|
667
|
+
// Issue #638: a phrase-free reply that missed only the parked marker is held for a run parking on its target, or
|
|
668
|
+
// enqueued as a parked reply when a run just parked there; otherwise this is today's drop, byte for byte.
|
|
669
|
+
const rescued = await heldBeforeLabelOr({ steer, res, kind: "azure", delivery, result, targetOf: sepTarget });
|
|
670
|
+
if (rescued === "held") return;
|
|
671
|
+
if (rescued === "enqueue") {
|
|
672
|
+
result = { enqueue: true, job: result.heldCandidate };
|
|
673
|
+
} else {
|
|
674
|
+
log?.({ event: "dropped", delivery, reason: result.reason });
|
|
675
|
+
return respond(res, 204);
|
|
676
|
+
}
|
|
453
677
|
}
|
|
454
678
|
|
|
679
|
+
// `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
|
|
680
|
+
// discrimination the semantic dedup key makes.
|
|
681
|
+
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
682
|
+
const target = `${result.job.repo}${sep}${result.job.target.number}`;
|
|
683
|
+
const steered = await steerOrEnqueue({ steer, res, kind: "azure", delivery, job: result.job, target });
|
|
684
|
+
if (steered.handled) return;
|
|
685
|
+
const job = steered.job;
|
|
686
|
+
|
|
455
687
|
let outcome;
|
|
456
688
|
try {
|
|
457
|
-
outcome = await fanout(
|
|
689
|
+
outcome = await fanout(job, async (j) => await enqueueForgeJob(await routeTo("azure", j), "azure", j));
|
|
458
690
|
} catch (err) {
|
|
459
|
-
|
|
460
|
-
|
|
691
|
+
// Azure resends the same notification, so the same `id`, for about 3 minutes, then on probation
|
|
692
|
+
// for about 36 hours, losing new events meanwhile (WHAT A 503 MEANS).
|
|
693
|
+
return answerEnqueueFailed({ log, res, delivery, err });
|
|
461
694
|
}
|
|
462
695
|
|
|
463
|
-
|
|
464
|
-
// discrimination the semantic dedup key makes.
|
|
465
|
-
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
466
|
-
return respondEnqueueOutcome({ log, res, delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, outcome });
|
|
696
|
+
return await answerEnqueueOutcome({ steer, kind: "azure", log, res, delivery, job, target, outcome });
|
|
467
697
|
});
|
|
468
698
|
}
|
package/src/start.mjs
CHANGED
|
@@ -30,9 +30,11 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import http from "node:http";
|
|
33
|
+
import { workerName } from "@edgehero/pi-dispatch/config";
|
|
34
|
+
import { makeReceiverPublisher, receiverBind } from "@edgehero/pi-dispatch/receiver-status";
|
|
33
35
|
import { readFileSync, watch } from "node:fs";
|
|
34
36
|
import { dirname, basename } from "node:path";
|
|
35
|
-
import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs";
|
|
37
|
+
import { loadReceiverConfig, servedForges, triggersFilePath, reloadTriggers } from "./config.mjs";
|
|
36
38
|
import { makeReceiver } from "./receiver.mjs";
|
|
37
39
|
import { entryExitCode } from "./cli.mjs";
|
|
38
40
|
import { installRejectionPrinter } from "@edgehero/pi-dispatch/exit-code";
|
|
@@ -52,6 +54,11 @@ import { WATCH_DEBOUNCE_MS, changedWhileArming, makeWatchCloser, readBeforeArmin
|
|
|
52
54
|
import { githubFailureFields } from "@edgehero/pi-dispatch/octokit-log";
|
|
53
55
|
import { retryIdentity } from "./boot-retry.mjs";
|
|
54
56
|
|
|
57
|
+
const RECEIVER_VERSION = (() => {
|
|
58
|
+
try { return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? ""; }
|
|
59
|
+
catch { return ""; }
|
|
60
|
+
})();
|
|
61
|
+
|
|
55
62
|
/**
|
|
56
63
|
* Boot the receiver. Collaborators are injected (defaulting to the real ones) so the whole wiring is
|
|
57
64
|
* testable offline with no GitHub, no Valkey, and no socket. Returns the listening server.
|
|
@@ -67,6 +74,7 @@ export async function startReceiver(
|
|
|
67
74
|
makeQueueFn = makeQueue,
|
|
68
75
|
makeForgeRouterFn = makeForgeRouter,
|
|
69
76
|
createServer = http.createServer,
|
|
77
|
+
makeReceiverPublisherFn = makeReceiverPublisher,
|
|
70
78
|
resolveGitLabSelfId: resolveSelfIdFn = resolveGitLabSelfId,
|
|
71
79
|
makeResolveAuthority: makeResolveAuthorityFn = makeResolveAuthority,
|
|
72
80
|
resolveForgejoSelfId: resolveForgejoSelfIdFn = resolveForgejoSelfId,
|
|
@@ -238,11 +246,23 @@ export async function startReceiver(
|
|
|
238
246
|
};
|
|
239
247
|
}
|
|
240
248
|
|
|
241
|
-
const handler = makeReceiver({ queue, router, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority });
|
|
249
|
+
const handler = makeReceiver({ queue, router, selfId, cfg, log, gitlab, forgejo, azure, resolveAuthority, watchEnv: env, now });
|
|
242
250
|
const server = createServer(handler);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
251
|
+
let receiverPublisher = null;
|
|
252
|
+
server.listen(cfg.port, cfg.bind, () => {
|
|
253
|
+
log({ event: "receiver_started", port: cfg.port, bind: cfg.bind, valkey: cfg.valkeyUrl });
|
|
254
|
+
// A socket, not configuration, proves where the process actually listens. Publication is optional:
|
|
255
|
+
// an unusable name or missing socket fact must never change the receiver's startup decision.
|
|
256
|
+
try {
|
|
257
|
+
const socket = server.address?.();
|
|
258
|
+
if (!socket || typeof socket !== "object" || !Number.isInteger(socket.port) || socket.port < 1 || socket.port > 65535) throw new Error("socket");
|
|
259
|
+
const name = workerName(env);
|
|
260
|
+
const publisher = makeReceiverPublisherFn({ client: () => queue.client, name, version: RECEIVER_VERSION, port: socket?.port, bind: receiverBind(socket?.address), forges: servedForges(cfg), observations: handler.observations, now, log: (event, fields) => log({ event, ...fields }) });
|
|
261
|
+
receiverPublisher = publisher;
|
|
262
|
+
closers.push(publisher);
|
|
263
|
+
publisher.start();
|
|
264
|
+
} catch { try { log({ event: "receiver_status_unavailable", reason: "identity-or-socket" }); } catch {} }
|
|
265
|
+
});
|
|
246
266
|
|
|
247
267
|
// The watch arms UNCONDITIONALLY now (issue #301). Armed only on the real entry, the lifecycle defect
|
|
248
268
|
// was muted under test rather than closed, and this file had no coverage that the watch arms at all --
|
|
@@ -264,6 +284,7 @@ export async function startReceiver(
|
|
|
264
284
|
const shutdown = async (signal) => {
|
|
265
285
|
log({ event: "receiver_stopping", signal });
|
|
266
286
|
await new Promise((resolve) => server.close(resolve));
|
|
287
|
+
try { receiverPublisher?.close(); } catch {}
|
|
267
288
|
await queue.close();
|
|
268
289
|
await router.close();
|
|
269
290
|
// The watch closer, and whatever joins it later. Per-item try, because a throw here would strand
|