@edgehero/pi-dispatch-receiver 1.0.0 → 1.2.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 +2 -2
- package/src/config.mjs +3 -3
- package/src/filter-azure.mjs +18 -0
- package/src/filter-forgejo.mjs +18 -0
- package/src/filter-gitlab.mjs +18 -0
- package/src/filter.mjs +11 -0
- package/src/poller.mjs +4 -1
- package/src/receiver.mjs +45 -20
- package/src/start.mjs +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-receiver",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.
|
|
48
|
+
"@edgehero/pi-dispatch": "^1.3.0",
|
|
49
49
|
"bullmq": "5.80.4",
|
|
50
50
|
"ioredis": "5.11.1"
|
|
51
51
|
}
|
package/src/config.mjs
CHANGED
|
@@ -283,13 +283,13 @@ function loadTriggers(env, readFile, fileExists) {
|
|
|
283
283
|
}
|
|
284
284
|
const group = groups[run.kind];
|
|
285
285
|
if (on.type === "label") {
|
|
286
|
-
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, replicas: run.replicas, repository: run.repository });
|
|
286
|
+
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 });
|
|
287
287
|
} else if (on.type === "comment") {
|
|
288
|
-
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, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
|
|
288
|
+
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
|
|
289
289
|
} else if (on.type === "pull_request") {
|
|
290
290
|
// `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
|
|
291
291
|
// presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
|
|
292
|
-
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, replicas: run.replicas });
|
|
292
|
+
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 });
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
package/src/filter-azure.mjs
CHANGED
|
@@ -114,8 +114,17 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
|
|
|
114
114
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
115
115
|
// basename(folder) restraint already exists to prevent.
|
|
116
116
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
117
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
118
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
119
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
120
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
121
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
117
122
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
118
123
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
124
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
125
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
126
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
127
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
119
128
|
trigger: {
|
|
120
129
|
event,
|
|
121
130
|
action: resolved.action,
|
|
@@ -177,8 +186,11 @@ function matchLabelRules(subset, triggers, labels, action) {
|
|
|
177
186
|
packages: rule.packages,
|
|
178
187
|
image: rule.image,
|
|
179
188
|
skillsDir: rule.skillsDir,
|
|
189
|
+
secrets: rule.secrets,
|
|
190
|
+
secretsProfile: rule.secretsProfile,
|
|
180
191
|
instructions: rule.instructions,
|
|
181
192
|
resume: rule.resume,
|
|
193
|
+
replicas: rule.replicas,
|
|
182
194
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
183
195
|
target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
|
|
184
196
|
};
|
|
@@ -217,8 +229,11 @@ function routeComment(subset, triggers, knownFlows, targetType) {
|
|
|
217
229
|
packages: triggers.comment.packages,
|
|
218
230
|
image: triggers.comment.image,
|
|
219
231
|
skillsDir: triggers.comment.skillsDir,
|
|
232
|
+
secrets: triggers.comment.secrets,
|
|
233
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
220
234
|
instructions: triggers.comment.instructions,
|
|
221
235
|
resume: triggers.comment.resume,
|
|
236
|
+
replicas: triggers.comment.replicas,
|
|
222
237
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
223
238
|
// No author_association: Azure has none, and the authority that admitted this comment was resolved
|
|
224
239
|
// from the graph, not read off the body.
|
|
@@ -246,8 +261,11 @@ function routePullRequest(subset, triggers, action) {
|
|
|
246
261
|
packages: rule.packages,
|
|
247
262
|
image: rule.image,
|
|
248
263
|
skillsDir: rule.skillsDir,
|
|
264
|
+
secrets: rule.secrets,
|
|
265
|
+
secretsProfile: rule.secretsProfile,
|
|
249
266
|
instructions: rule.instructions,
|
|
250
267
|
resume: rule.resume,
|
|
268
|
+
replicas: rule.replicas,
|
|
251
269
|
matched: { index: rule.index, type: "pull_request", action },
|
|
252
270
|
target: {
|
|
253
271
|
type: "pull_request",
|
package/src/filter-forgejo.mjs
CHANGED
|
@@ -116,8 +116,17 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
|
|
|
116
116
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
117
117
|
// basename(folder) restraint already exists to prevent.
|
|
118
118
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
119
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
120
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
121
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
122
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
123
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
119
124
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
120
125
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
126
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
127
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
128
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
129
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
121
130
|
trigger: {
|
|
122
131
|
event: eventName,
|
|
123
132
|
// Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
|
|
@@ -145,8 +154,11 @@ function routeIssueLabel(subset, triggers) {
|
|
|
145
154
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
146
155
|
image: rule.image,
|
|
147
156
|
skillsDir: rule.skillsDir,
|
|
157
|
+
secrets: rule.secrets,
|
|
158
|
+
secretsProfile: rule.secretsProfile,
|
|
148
159
|
instructions: rule.instructions,
|
|
149
160
|
resume: rule.resume,
|
|
161
|
+
replicas: rule.replicas,
|
|
150
162
|
matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
|
|
151
163
|
target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
|
|
152
164
|
};
|
|
@@ -190,8 +202,11 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
190
202
|
packages: triggers.comment.packages,
|
|
191
203
|
image: triggers.comment.image,
|
|
192
204
|
skillsDir: triggers.comment.skillsDir,
|
|
205
|
+
secrets: triggers.comment.secrets,
|
|
206
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
193
207
|
instructions: triggers.comment.instructions,
|
|
194
208
|
resume: triggers.comment.resume,
|
|
209
|
+
replicas: triggers.comment.replicas,
|
|
195
210
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
196
211
|
// The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
|
|
197
212
|
// authority that admitted this comment was resolved from the API, not read off the body.
|
|
@@ -217,8 +232,11 @@ function routePullRequest(subset, triggers, action) {
|
|
|
217
232
|
packages: rule.packages,
|
|
218
233
|
image: rule.image,
|
|
219
234
|
skillsDir: rule.skillsDir,
|
|
235
|
+
secrets: rule.secrets,
|
|
236
|
+
secretsProfile: rule.secretsProfile,
|
|
220
237
|
instructions: rule.instructions,
|
|
221
238
|
resume: rule.resume,
|
|
239
|
+
replicas: rule.replicas,
|
|
222
240
|
matched: { index: rule.index, type: "pull_request", action },
|
|
223
241
|
target: {
|
|
224
242
|
type: "pull_request",
|
package/src/filter-gitlab.mjs
CHANGED
|
@@ -107,10 +107,19 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
|
|
|
107
107
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
108
108
|
// basename(folder) restraint already exists to prevent.
|
|
109
109
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
110
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
111
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
112
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
113
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
114
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
110
115
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
111
116
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
112
117
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
113
118
|
...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
|
|
119
|
+
// How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
|
|
120
|
+
// reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
|
|
121
|
+
// unflagged job's data must stay byte-identical to today's.
|
|
122
|
+
...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
|
|
114
123
|
trigger: {
|
|
115
124
|
event: kind,
|
|
116
125
|
action: subset.action,
|
|
@@ -143,8 +152,11 @@ function routeLabel(subset, triggers, targetType) {
|
|
|
143
152
|
packages: rule.packages,
|
|
144
153
|
image: rule.image,
|
|
145
154
|
skillsDir: rule.skillsDir,
|
|
155
|
+
secrets: rule.secrets,
|
|
156
|
+
secretsProfile: rule.secretsProfile,
|
|
146
157
|
instructions: rule.instructions,
|
|
147
158
|
resume: rule.resume,
|
|
159
|
+
replicas: rule.replicas,
|
|
148
160
|
matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
|
|
149
161
|
target: buildTarget(subset, targetType),
|
|
150
162
|
};
|
|
@@ -203,8 +215,11 @@ function mrResult(subset, rule, matched) {
|
|
|
203
215
|
packages: rule.packages,
|
|
204
216
|
image: rule.image,
|
|
205
217
|
skillsDir: rule.skillsDir,
|
|
218
|
+
secrets: rule.secrets,
|
|
219
|
+
secretsProfile: rule.secretsProfile,
|
|
206
220
|
instructions: rule.instructions,
|
|
207
221
|
resume: rule.resume,
|
|
222
|
+
replicas: rule.replicas,
|
|
208
223
|
matched,
|
|
209
224
|
target: buildTarget(subset, "pull_request"),
|
|
210
225
|
};
|
|
@@ -249,8 +264,11 @@ function routeNote(subset, triggers, knownFlows) {
|
|
|
249
264
|
packages: triggers.comment.packages,
|
|
250
265
|
image: triggers.comment.image,
|
|
251
266
|
skillsDir: triggers.comment.skillsDir,
|
|
267
|
+
secrets: triggers.comment.secrets,
|
|
268
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
252
269
|
instructions: triggers.comment.instructions,
|
|
253
270
|
resume: triggers.comment.resume,
|
|
271
|
+
replicas: triggers.comment.replicas,
|
|
254
272
|
matched: { index: triggers.comment.index, type: "comment", phrase },
|
|
255
273
|
target: buildTarget(subset, targetType),
|
|
256
274
|
// The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
|
package/src/filter.mjs
CHANGED
|
@@ -121,6 +121,11 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
|
|
|
121
121
|
// /job/event.json, and a worker-host path in an agent-readable file is the leak prepare-local's
|
|
122
122
|
// basename(folder) restraint already exists to prevent.
|
|
123
123
|
...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
|
|
124
|
+
// REQ-TRIGGER-SECRETS. Conditional exactly like skillsDir above, so a trigger that binds no
|
|
125
|
+
// secret enqueues the job data it always has. The map holds REFERENCES, never values: the
|
|
126
|
+
// receiver has no resolver and reaches no vault -- the worker resolves them pre-spend.
|
|
127
|
+
...(resolved.secrets !== undefined ? { secrets: resolved.secrets } : {}),
|
|
128
|
+
...(resolved.secretsProfile !== undefined ? { secretsProfile: resolved.secretsProfile } : {}),
|
|
124
129
|
...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
|
|
125
130
|
// Conditional like packages/image, and for the same reason: an unflagged job's data must stay
|
|
126
131
|
// byte-identical to today's, so the key is absent rather than present-and-undefined.
|
|
@@ -166,6 +171,8 @@ function routeIssueLabel(subset, triggers) {
|
|
|
166
171
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
167
172
|
image: rule.image,
|
|
168
173
|
skillsDir: rule.skillsDir,
|
|
174
|
+
secrets: rule.secrets,
|
|
175
|
+
secretsProfile: rule.secretsProfile,
|
|
169
176
|
instructions: rule.instructions,
|
|
170
177
|
resume: rule.resume,
|
|
171
178
|
replicas: rule.replicas,
|
|
@@ -223,6 +230,8 @@ function routeComment(subset, triggers, knownFlows) {
|
|
|
223
230
|
packages: triggers.comment.packages,
|
|
224
231
|
image: triggers.comment.image,
|
|
225
232
|
skillsDir: triggers.comment.skillsDir,
|
|
233
|
+
secrets: triggers.comment.secrets,
|
|
234
|
+
secretsProfile: triggers.comment.secretsProfile,
|
|
226
235
|
instructions: triggers.comment.instructions,
|
|
227
236
|
resume: triggers.comment.resume,
|
|
228
237
|
replicas: triggers.comment.replicas,
|
|
@@ -318,6 +327,8 @@ function routePullRequest(subset, triggers, action) {
|
|
|
318
327
|
packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
|
|
319
328
|
image: rule.image,
|
|
320
329
|
skillsDir: rule.skillsDir,
|
|
330
|
+
secrets: rule.secrets,
|
|
331
|
+
secretsProfile: rule.secretsProfile,
|
|
321
332
|
instructions: rule.instructions,
|
|
322
333
|
resume: rule.resume,
|
|
323
334
|
replicas: rule.replicas,
|
package/src/poller.mjs
CHANGED
|
@@ -804,7 +804,10 @@ function makeAppInstallationTokenFn(github, { fetchFn, readFile, now }) {
|
|
|
804
804
|
let cached = null; // { token, expiresAtMs }
|
|
805
805
|
return async () => {
|
|
806
806
|
if (cached !== null && cached.expiresAtMs - now() > 5 * 60_000) return cached.token;
|
|
807
|
-
|
|
807
|
+
// Inline key (GITHUB_APP_PRIVATE_KEY) when the operator supplied one, the file otherwise. The shared
|
|
808
|
+
// loadGitHubAuth normalised and shape-checked it at load and refuses both-set, so there is nothing to
|
|
809
|
+
// decide here (issue #208).
|
|
810
|
+
const pem = github.privateKey ?? (await readFile(github.privateKeyPath, "utf8"));
|
|
808
811
|
const jwt = appJwt(github.appId, pem, now());
|
|
809
812
|
const res = await fetchFn(`${API_URL}/app/installations/${github.installationId}/access_tokens`, {
|
|
810
813
|
method: "POST",
|
package/src/receiver.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The webhook receiver: a thin producer that turns a verified
|
|
2
|
+
* The webhook receiver: a thin producer that turns a verified forge webhook into (at most) one queued
|
|
3
3
|
* job -- or, when the matched trigger opted into `run.replicas`, into exactly that many independent ones
|
|
4
|
-
* (REQ-REPLICA-RUNS
|
|
4
|
+
* (REQ-REPLICA-RUNS, on every forge since #187; the poller stays github-only, so replicas on the other
|
|
5
|
+
* three arrive by webhook alone). It composes the three pieces that own the hard parts -- `makeVerifiedHandler` (the
|
|
5
6
|
* HMAC trust boundary), `filter` (the trigger/author gate), and the SHARED `enqueueGitHubJob` -- and adds
|
|
6
7
|
* only the glue: parse the verified body, project the payload subset, route on the filter's verdict, and
|
|
7
8
|
* map the outcome to a status code.
|
|
@@ -169,6 +170,36 @@ function pathOf(url) {
|
|
|
169
170
|
return raw.length > 1 && raw.endsWith("/") ? raw.slice(0, -1) : raw;
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* REPLICA FANOUT (REQ-REPLICA-RUNS). The one place a single delivery becomes more than one job, shared by
|
|
175
|
+
* all four forge arms since #187 widened `run.replicas` past github.
|
|
176
|
+
*
|
|
177
|
+
* ONE body rather than four, for the reason `enqueueForgeJob` gives for collapsing its own wrappers
|
|
178
|
+
* (queue.mjs): four copies is four places for one of them to be quietly weakened while every test stays
|
|
179
|
+
* green. What would be weakened here is the `replicas > 1` conditional, whose whole job is byte-identity
|
|
180
|
+
* for an unflagged delivery -- spread `replica: i` unconditionally in one arm and that forge's `data`, its
|
|
181
|
+
* jobId and its dedup id all change for every ordinary job, with no test outside that forge to notice.
|
|
182
|
+
*
|
|
183
|
+
* Absent `replicas` is `1` and the call below is byte-identical to the single enqueue it replaced: no
|
|
184
|
+
* `replica` key on the job, so the jobId, the dedup id and `data` are exactly what they were.
|
|
185
|
+
*
|
|
186
|
+
* PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
|
|
187
|
+
* replica k throws, the caller's catch answers 503, the forge redelivers, replicas 1..k-1 dedup on their
|
|
188
|
+
* own now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
|
|
189
|
+
*
|
|
190
|
+
* `enqueue` is a callback because the four arms spell their enqueue differently (a named github/gitlab
|
|
191
|
+
* wrapper, or `enqueueForgeJob` with an explicit kind); the fanout itself is forge-blind.
|
|
192
|
+
*
|
|
193
|
+
* @returns {Promise<number>} how many jobs were enqueued, for the caller's `enqueued` log line.
|
|
194
|
+
*/
|
|
195
|
+
async function fanout(job, enqueue) {
|
|
196
|
+
const replicas = job.replicas ?? 1;
|
|
197
|
+
for (let i = 1; i <= replicas; i++) {
|
|
198
|
+
await enqueue(replicas > 1 ? { ...job, replica: i } : job);
|
|
199
|
+
}
|
|
200
|
+
return replicas;
|
|
201
|
+
}
|
|
202
|
+
|
|
172
203
|
/**
|
|
173
204
|
* The GitHub arm. Returns `makeVerifiedHandler`'s handler directly, so it only ever sees an already-verified
|
|
174
205
|
* request. `onVerified` owns parse, filter, enqueue, and response; a good signature is the sole
|
|
@@ -189,19 +220,10 @@ function makeGitHubHandler({ queue, selfId, cfg, log }) {
|
|
|
189
220
|
return respond(res, 204);
|
|
190
221
|
}
|
|
191
222
|
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
// the call below is byte-identical to the single enqueue it replaced -- no `replica` key on the job,
|
|
195
|
-
// so the jobId, the dedup id and `data` are all exactly what they were.
|
|
196
|
-
//
|
|
197
|
-
// PARTIAL FAILURE IS IDEMPOTENT BY CONSTRUCTION, which is why there is no compensating logic here: if
|
|
198
|
-
// replica k throws, the catch below answers 503, GitHub redelivers, replicas 1..k-1 dedup on their own
|
|
199
|
-
// now-taken jobIds and k..n enqueue. The retry converges on exactly n jobs rather than n + (k-1).
|
|
200
|
-
const replicas = result.job.replicas ?? 1;
|
|
223
|
+
// Fanout (REQ-REPLICA-RUNS) lives in `fanout` above; the 202/503 decision stays here, where it always was.
|
|
224
|
+
let replicas;
|
|
201
225
|
try {
|
|
202
|
-
|
|
203
|
-
await enqueueGitHubJob(queue, replicas > 1 ? { ...result.job, replica: i } : result.job);
|
|
204
|
-
}
|
|
226
|
+
replicas = await fanout(result.job, (j) => enqueueGitHubJob(queue, j));
|
|
205
227
|
} catch (err) {
|
|
206
228
|
// Own try/catch so a Valkey-down enqueue is a 503 (retryable), not verify's outer 500.
|
|
207
229
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
@@ -249,8 +271,9 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
|
|
|
249
271
|
return respond(res, 204);
|
|
250
272
|
}
|
|
251
273
|
|
|
274
|
+
let replicas;
|
|
252
275
|
try {
|
|
253
|
-
await enqueueGitLabJob(queue,
|
|
276
|
+
replicas = await fanout(result.job, (j) => enqueueGitLabJob(queue, j));
|
|
254
277
|
} catch (err) {
|
|
255
278
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
256
279
|
return respond(res, 503, { error: "enqueue-failed" }); // GitLab redelivers; dedup by webhook-id coalesces
|
|
@@ -259,7 +282,7 @@ function makeGitLabHandler({ queue, cfg, log, mode, secret, selfId, resolveAutho
|
|
|
259
282
|
// `!` for a merge request, `#` for an issue -- GitLab's own notation, and the same discrimination
|
|
260
283
|
// the semantic dedup key makes, because the two are separate number sequences.
|
|
261
284
|
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
262
|
-
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
|
|
285
|
+
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, replicas });
|
|
263
286
|
return respond(res, 202, { status: "queued" });
|
|
264
287
|
});
|
|
265
288
|
}
|
|
@@ -300,14 +323,15 @@ function makeForgejoHandler({ queue, cfg, log, secret, selfId, resolveAuthority
|
|
|
300
323
|
return respond(res, 204);
|
|
301
324
|
}
|
|
302
325
|
|
|
326
|
+
let replicas;
|
|
303
327
|
try {
|
|
304
|
-
await enqueueForgeJob(queue, "forgejo",
|
|
328
|
+
replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "forgejo", j));
|
|
305
329
|
} catch (err) {
|
|
306
330
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
307
331
|
return respond(res, 503, { error: "enqueue-failed" }); // Forgejo redelivers; dedup by GUID coalesces
|
|
308
332
|
}
|
|
309
333
|
|
|
310
|
-
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow });
|
|
334
|
+
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.target.type}#${result.job.target.number}`, flow: result.job.flow, replicas });
|
|
311
335
|
return respond(res, 202, { status: "queued" });
|
|
312
336
|
});
|
|
313
337
|
}
|
|
@@ -357,8 +381,9 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
|
|
|
357
381
|
return respond(res, 204);
|
|
358
382
|
}
|
|
359
383
|
|
|
384
|
+
let replicas;
|
|
360
385
|
try {
|
|
361
|
-
await enqueueForgeJob(queue, "azure",
|
|
386
|
+
replicas = await fanout(result.job, (j) => enqueueForgeJob(queue, "azure", j));
|
|
362
387
|
} catch (err) {
|
|
363
388
|
log?.({ event: "enqueue_failed", delivery, reason: err?.message });
|
|
364
389
|
return respond(res, 503, { error: "enqueue-failed" });
|
|
@@ -367,7 +392,7 @@ function makeAzureHandler({ queue, cfg, log, mode, secret, headerName, selfId, r
|
|
|
367
392
|
// `!` for a pull request, `#` for a work item -- Azure numbers them separately, and this is the same
|
|
368
393
|
// discrimination the semantic dedup key makes.
|
|
369
394
|
const sep = result.job.target.type === "pull_request" ? "!" : "#";
|
|
370
|
-
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow });
|
|
395
|
+
log?.({ event: "enqueued", delivery, repo: result.job.repo, target: `${result.job.repo}${sep}${result.job.target.number}`, flow: result.job.flow, replicas });
|
|
371
396
|
return respond(res, 202, { status: "queued" });
|
|
372
397
|
});
|
|
373
398
|
}
|
package/src/start.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { watch } from "node:fs";
|
|
|
28
28
|
import { dirname, basename } from "node:path";
|
|
29
29
|
import { loadReceiverConfig, triggersFilePath, reloadTriggers } from "./config.mjs";
|
|
30
30
|
import { makeReceiver } from "./receiver.mjs";
|
|
31
|
+
import { entryExitCode } from "./cli.mjs";
|
|
31
32
|
import { makeGitHubAuth } from "@edgehero/pi-dispatch/get-token";
|
|
32
33
|
import { resolveGitLabSelfId } from "@edgehero/pi-dispatch/gitlab-identity";
|
|
33
34
|
import { resolveForgejoSelfId } from "@edgehero/pi-dispatch/forgejo-identity";
|
|
@@ -192,6 +193,12 @@ function watchTriggers(env, cfg, log) {
|
|
|
192
193
|
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("start.mjs")) {
|
|
193
194
|
startReceiver(process.env).catch((err) => {
|
|
194
195
|
process.stderr.write(`${JSON.stringify({ event: "receiver_start_failed", reason: err?.message })}\n`);
|
|
195
|
-
|
|
196
|
+
// entryExitCode, NOT a bare 1. This file is what `receiver.service` execs -- cli.mjs is not on that
|
|
197
|
+
// path -- so the mapping cli.mjs documents ("a supervisor restarting on exit 2 would loop on a config
|
|
198
|
+
// that can never parse") only reaches a real deployment from here. A tagged config refusal exits 2
|
|
199
|
+
// and `RestartPreventExitStatus=2` stops the unit; anything else is infra and stays retryable at 1.
|
|
200
|
+
// IMPORTED rather than restated: two copies of an exit-code rule is one place for it to drift, and
|
|
201
|
+
// the copy that drifts is the one nobody is looking at.
|
|
202
|
+
process.exitCode = entryExitCode(err);
|
|
196
203
|
});
|
|
197
204
|
}
|