@edgehero/pi-dispatch-receiver 0.2.1 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-receiver",
3
- "version": "0.2.1",
3
+ "version": "1.1.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": "^0.3.0",
48
+ "@edgehero/pi-dispatch": "^1.0.0",
49
49
  "bullmq": "5.80.4",
50
50
  "ioredis": "5.11.1"
51
51
  }
package/src/config.mjs CHANGED
@@ -226,11 +226,13 @@ function loadAzureConfig(env) {
226
226
  * The shared `parseTriggers` validates the WHOLE file (including the on x run matrix and cron entries the
227
227
  * worker owns); this loader keeps only the webhook types and groups them PER FORGE, so `cfg.triggers` is
228
228
  * `{ github: <group>, gitlab: <group>, knownFlows }` where each group is:
229
- * - `label`: ordered `{ index, predicate, flow, packages, image }` rules (first match wins in the filter).
230
- * - `comment`: the single `{ index, phrase, defaultFlow, packages, image }` (or null when no comment trigger is configured).
231
- * - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, packages, image }` rules.
229
+ * - `label`: ordered `{ index, predicate, flow, command, packages, image }` rules (first match wins in the filter).
230
+ * - `comment`: the single `{ index, phrase, defaultFlow, command, packages, image }` (or null when no comment trigger is configured).
231
+ * - `pullRequest`: ordered `{ index, actions:Set, predicate, flow, command, packages, image }` rules.
232
232
  * and `knownFlows` is every webhook `run.flow`, so a comment's `<phrase> <flow>` override cannot summon an
233
- * unlisted flow.
233
+ * unlisted flow. A rule carries EITHER `flow` or `command` (issue #189; the shared parser enforces the
234
+ * exclusivity): a command rule's match dispatches a registered pi extension command in the container, so
235
+ * it resolves no flow and contributes nothing to the flow vocabulary.
234
236
  *
235
237
  * Grouping by forge FIRST is what keeps each forge's gate reading only its own rules: a GitLab delivery
236
238
  * can never match a rule an operator wrote for GitHub, even when both name the same label. `knownFlows`
@@ -272,16 +274,22 @@ function loadTriggers(env, readFile, fileExists) {
272
274
 
273
275
  for (const [index, { on, run }] of parsed.entries()) {
274
276
  if (on.type === "cron") continue; // the worker owns cron; the receiver never fires it -- but it keeps its index
275
- knownFlows.add(run.flow);
277
+ // Guarded because a command trigger (issue #189) has NO run.flow, and `Set.add(undefined)` would put
278
+ // undefined into the comment `<phrase> <flow>` override allowlist. That set exists to bound which
279
+ // names a collaborator's comment may summon; polluting it with a non-name is how a later loose
280
+ // membership test (or a `[...set]` enumeration in a message) starts treating "no flow" as a flow.
281
+ if (typeof run.flow === "string") {
282
+ knownFlows.add(run.flow);
283
+ }
276
284
  const group = groups[run.kind];
277
285
  if (on.type === "label") {
278
- group.label.push({ index, predicate: { any: on.any, all: on.all, none: on.none }, flow: run.flow, 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, replicas: run.replicas, repository: run.repository });
279
287
  } else if (on.type === "comment") {
280
- group.comment = { index, phrase: on.phrase, defaultFlow: run.flow, 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, replicas: run.replicas, repository: run.repository }; // parseTriggers guarantees at most one per forge
281
289
  } else if (on.type === "pull_request") {
282
290
  // `reviewStates` is null rather than an empty Set when unnarrowed: the filter tests it for
283
291
  // presence, and an empty Set would read as "no verdict matches" and silently refuse everything.
284
- 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, 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, replicas: run.replicas });
285
293
  }
286
294
  }
287
295
 
@@ -102,7 +102,11 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
102
102
  // gitlab makes when it carries `projectId` next to `repo`.
103
103
  azure: resolved.azure,
104
104
  target: resolved.target,
105
- flow: resolved.flow,
105
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
106
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
107
+ // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
108
+ // literal byte-identical to what it always was -- the same move filter.mjs makes.
109
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
106
110
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
107
111
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
108
112
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
@@ -112,6 +116,10 @@ export function filterAzure(subset, triggers, knownFlows, selfId, authorized, de
112
116
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
113
117
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
114
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 } : {}),
115
123
  trigger: {
116
124
  event,
117
125
  action: resolved.action,
@@ -167,13 +175,15 @@ function matchLabelRules(subset, triggers, labels, action) {
167
175
  return {
168
176
  enqueue: true,
169
177
  action,
170
- flow: rule.flow,
178
+ // A command rule (issue #189) skips flow resolution entirely: the tag match IS the dispatch.
179
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
171
180
  repository: rule.repository,
172
181
  packages: rule.packages,
173
182
  image: rule.image,
174
183
  skillsDir: rule.skillsDir,
175
184
  instructions: rule.instructions,
176
185
  resume: rule.resume,
186
+ replicas: rule.replicas,
177
187
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
178
188
  target: { type: "issue", number: subset.target?.number, title: subset.target?.title, body: subset.target?.body },
179
189
  };
@@ -186,24 +196,35 @@ function routeComment(subset, triggers, knownFlows, targetType) {
186
196
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
187
197
  return { enqueue: false, reason: "no-trigger-phrase" };
188
198
  }
189
- let flow = triggers.comment?.defaultFlow;
190
- const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
191
- if (match && knownFlows?.has(match[1])) {
192
- flow = match[1];
193
- }
194
- if (flow === null || flow === undefined || flow === "") {
195
- return { enqueue: false, reason: "no-flow" };
199
+ // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
200
+ // trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
201
+ // fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
202
+ // active override would hand any authorized member two levers the trigger's author never granted:
203
+ // retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
204
+ // word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
205
+ const command = triggers.comment.command;
206
+ let flow;
207
+ if (command === undefined) {
208
+ flow = triggers.comment?.defaultFlow;
209
+ const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
210
+ if (match && knownFlows?.has(match[1])) {
211
+ flow = match[1];
212
+ }
213
+ if (flow === null || flow === undefined || flow === "") {
214
+ return { enqueue: false, reason: "no-flow" };
215
+ }
196
216
  }
197
217
  return {
198
218
  enqueue: true,
199
219
  action: "commented",
200
- flow,
220
+ ...(command !== undefined ? { command } : { flow }),
201
221
  repository: triggers.comment.repository,
202
222
  packages: triggers.comment.packages,
203
223
  image: triggers.comment.image,
204
224
  skillsDir: triggers.comment.skillsDir,
205
225
  instructions: triggers.comment.instructions,
206
226
  resume: triggers.comment.resume,
227
+ replicas: triggers.comment.replicas,
207
228
  matched: { index: triggers.comment.index, type: "comment", phrase },
208
229
  // No author_association: Azure has none, and the authority that admitted this comment was resolved
209
230
  // from the graph, not read off the body.
@@ -225,13 +246,15 @@ function routePullRequest(subset, triggers, action) {
225
246
  return {
226
247
  enqueue: true,
227
248
  action,
228
- flow: rule.flow,
249
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
250
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
229
251
  repository: rule.repository,
230
252
  packages: rule.packages,
231
253
  image: rule.image,
232
254
  skillsDir: rule.skillsDir,
233
255
  instructions: rule.instructions,
234
256
  resume: rule.resume,
257
+ replicas: rule.replicas,
235
258
  matched: { index: rule.index, type: "pull_request", action },
236
259
  target: {
237
260
  type: "pull_request",
@@ -104,7 +104,11 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
104
104
  const job = {
105
105
  repo: subset.repository?.full_name,
106
106
  target: resolved.target,
107
- flow: resolved.flow,
107
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
108
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
109
+ // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
110
+ // literal byte-identical to what it always was -- the same move filter.mjs makes.
111
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
108
112
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
109
113
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
110
114
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
@@ -114,6 +118,10 @@ export function filterForgejo(eventName, subset, triggers, knownFlows, selfId, a
114
118
  ...(resolved.skillsDir !== undefined ? { skillsDir: resolved.skillsDir } : {}),
115
119
  ...(resolved.instructions !== undefined ? { instructions: resolved.instructions } : {}),
116
120
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
121
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
122
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
123
+ // unflagged job's data must stay byte-identical to today's.
124
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
117
125
  trigger: {
118
126
  event: eventName,
119
127
  // Forgejo's OWN word, not our translation of it. The run record should say what the forge said.
@@ -136,12 +144,14 @@ function routeIssueLabel(subset, triggers) {
136
144
  }
137
145
  return {
138
146
  enqueue: true,
139
- flow: rule.flow,
147
+ // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
148
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
140
149
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
141
150
  image: rule.image,
142
151
  skillsDir: rule.skillsDir,
143
152
  instructions: rule.instructions,
144
153
  resume: rule.resume,
154
+ replicas: rule.replicas,
145
155
  matched: { index: rule.index, type: "label", label: matchedLabel(L, rule.predicate) },
146
156
  target: { type: "issue", number: subset.issue?.number, title: subset.issue?.title, body: subset.issue?.body },
147
157
  };
@@ -154,15 +164,25 @@ function routeComment(subset, triggers, knownFlows) {
154
164
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
155
165
  return { enqueue: false, reason: "no-trigger-phrase" };
156
166
  }
157
- // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a known
158
- // flow name, so a comment cannot summon an unlisted flow.
159
- let flow = triggers.comment?.defaultFlow;
160
- const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
161
- if (match && knownFlows?.has(match[1])) {
162
- flow = match[1];
163
- }
164
- if (flow === null || flow === undefined || flow === "") {
165
- return { enqueue: false, reason: "no-flow" };
167
+ // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
168
+ // trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
169
+ // fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
170
+ // active override would hand any authorized collaborator two levers the trigger's author never granted:
171
+ // retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
172
+ // word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
173
+ const command = triggers.comment.command;
174
+ let flow;
175
+ if (command === undefined) {
176
+ // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a known
177
+ // flow name, so a comment cannot summon an unlisted flow.
178
+ flow = triggers.comment?.defaultFlow;
179
+ const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
180
+ if (match && knownFlows?.has(match[1])) {
181
+ flow = match[1];
182
+ }
183
+ if (flow === null || flow === undefined || flow === "") {
184
+ return { enqueue: false, reason: "no-flow" };
185
+ }
166
186
  }
167
187
  // `is_pull` is TOP-LEVEL on Forgejo, where GitHub carries `issue.pull_request`. Reading the wrong one
168
188
  // routes every pull-request comment as an issue, and the envelope then tells the agent to open
@@ -171,12 +191,13 @@ function routeComment(subset, triggers, knownFlows) {
171
191
  const isPR = subset.isPull === true;
172
192
  return {
173
193
  enqueue: true,
174
- flow,
194
+ ...(command !== undefined ? { command } : { flow }),
175
195
  packages: triggers.comment.packages,
176
196
  image: triggers.comment.image,
177
197
  skillsDir: triggers.comment.skillsDir,
178
198
  instructions: triggers.comment.instructions,
179
199
  resume: triggers.comment.resume,
200
+ replicas: triggers.comment.replicas,
180
201
  matched: { index: triggers.comment.index, type: "comment", phrase },
181
202
  // The invoking comment rides on the trigger. No author_association: Forgejo has none, and the
182
203
  // authority that admitted this comment was resolved from the API, not read off the body.
@@ -197,12 +218,14 @@ function routePullRequest(subset, triggers, action) {
197
218
  if (rule.predicate && !matchesRule(L, rule.predicate)) continue;
198
219
  return {
199
220
  enqueue: true,
200
- flow: rule.flow,
221
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
222
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
201
223
  packages: rule.packages,
202
224
  image: rule.image,
203
225
  skillsDir: rule.skillsDir,
204
226
  instructions: rule.instructions,
205
227
  resume: rule.resume,
228
+ replicas: rule.replicas,
206
229
  matched: { index: rule.index, type: "pull_request", action },
207
230
  target: {
208
231
  type: "pull_request",
@@ -95,7 +95,11 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
95
95
  repo: subset.project?.path,
96
96
  projectId: subset.project?.id,
97
97
  target: resolved.target,
98
- flow: resolved.flow,
98
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
99
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
100
+ // resolving a flow, and its job must carry NO flow key at all. The spread keeps a flow rule's
101
+ // literal byte-identical to what it always was -- the same move filter.mjs makes.
102
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
99
103
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
100
104
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
101
105
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
@@ -107,6 +111,10 @@ export function filterGitLab(subset, triggers, knownFlows, selfId, authorized, d
107
111
  // Conditional like packages/image, and for the same reason: an unflagged job's data must stay
108
112
  // byte-identical to today's, so the key is absent rather than present-and-undefined.
109
113
  ...(resolved.resume !== undefined ? { resume: resolved.resume } : {}),
114
+ // How many independent sandboxes race this flow (REQ-REPLICA-RUNS). At JOB level and conditional for the
115
+ // reasons filter.mjs states in full: receiver.mjs reads this to decide how many times to enqueue, and an
116
+ // unflagged job's data must stay byte-identical to today's.
117
+ ...(resolved.replicas !== undefined ? { replicas: resolved.replicas } : {}),
110
118
  trigger: {
111
119
  event: kind,
112
120
  action: subset.action,
@@ -134,12 +142,14 @@ function routeLabel(subset, triggers, targetType) {
134
142
  if (!rule) return { enqueue: false, reason: "no-allowlisted-label" };
135
143
  return {
136
144
  enqueue: true,
137
- flow: rule.flow,
145
+ // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
146
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
138
147
  packages: rule.packages,
139
148
  image: rule.image,
140
149
  skillsDir: rule.skillsDir,
141
150
  instructions: rule.instructions,
142
151
  resume: rule.resume,
152
+ replicas: rule.replicas,
143
153
  matched: { index: rule.index, type: "label", label: matchedLabel(added, rule.predicate) },
144
154
  target: buildTarget(subset, targetType),
145
155
  };
@@ -193,12 +203,14 @@ function routeMergeRequest(subset, triggers) {
193
203
  function mrResult(subset, rule, matched) {
194
204
  return {
195
205
  enqueue: true,
196
- flow: rule.flow,
206
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
207
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
197
208
  packages: rule.packages,
198
209
  image: rule.image,
199
210
  skillsDir: rule.skillsDir,
200
211
  instructions: rule.instructions,
201
212
  resume: rule.resume,
213
+ replicas: rule.replicas,
202
214
  matched,
203
215
  target: buildTarget(subset, "pull_request"),
204
216
  };
@@ -218,23 +230,34 @@ function routeNote(subset, triggers, knownFlows) {
218
230
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
219
231
  return { enqueue: false, reason: "no-trigger-phrase" };
220
232
  }
221
- // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
222
- // known flow name, so a comment cannot summon an unlisted flow.
223
- let flow = triggers.comment?.defaultFlow;
224
- const match = body.match(new RegExp(escapeLiteral(phrase) + "\\s+(\\S+)"));
225
- if (match && knownFlows?.has(match[1])) flow = match[1];
226
- if (flow === null || flow === undefined || flow === "") {
227
- return { enqueue: false, reason: "no-flow" };
233
+ // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
234
+ // trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
235
+ // fires, and trailing note text stays DATA (the handler reads the delivery via /job/event.json). An
236
+ // active override would hand any authorized member two levers the trigger's author never granted:
237
+ // retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
238
+ // word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
239
+ const command = triggers.comment.command;
240
+ let flow;
241
+ if (command === undefined) {
242
+ // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
243
+ // known flow name, so a comment cannot summon an unlisted flow.
244
+ flow = triggers.comment?.defaultFlow;
245
+ const match = body.match(new RegExp(escapeLiteral(phrase) + "\\s+(\\S+)"));
246
+ if (match && knownFlows?.has(match[1])) flow = match[1];
247
+ if (flow === null || flow === undefined || flow === "") {
248
+ return { enqueue: false, reason: "no-flow" };
249
+ }
228
250
  }
229
251
  const targetType = subset.noteableType === "MergeRequest" ? "pull_request" : "issue";
230
252
  return {
231
253
  enqueue: true,
232
- flow,
254
+ ...(command !== undefined ? { command } : { flow }),
233
255
  packages: triggers.comment.packages,
234
256
  image: triggers.comment.image,
235
257
  skillsDir: triggers.comment.skillsDir,
236
258
  instructions: triggers.comment.instructions,
237
259
  resume: triggers.comment.resume,
260
+ replicas: triggers.comment.replicas,
238
261
  matched: { index: triggers.comment.index, type: "comment", phrase },
239
262
  target: buildTarget(subset, targetType),
240
263
  // The invoking comment rides the job as DATA (CONST-ISSUE-TEXT-IS-DATA). No author_association
package/src/filter.mjs CHANGED
@@ -108,7 +108,12 @@ export function filter(eventName, subset, cfg, selfId, deliveryId) {
108
108
  const job = {
109
109
  repo: subset.repository?.full_name,
110
110
  target: resolved.target,
111
- flow: resolved.flow,
111
+ // EXACTLY ONE of flow/command, decided by the matched rule (issue #189; the shared parser enforces
112
+ // the exclusivity at load). A command rule dispatches a registered pi extension command instead of
113
+ // resolving a flow, and its job must carry NO flow key at all -- a present-and-undefined flow would
114
+ // change the enqueued bytes for every consumer that serializes the job. The spread keeps a flow
115
+ // rule's literal byte-identical to what it always was.
116
+ ...(resolved.command !== undefined ? { command: resolved.command } : { flow: resolved.flow }),
112
117
  ...(resolved.packages !== undefined ? { packages: resolved.packages } : {}),
113
118
  ...(resolved.image !== undefined ? { image: resolved.image } : {}),
114
119
  // The trigger's injected skills dir (REQ-PER-TRIGGER-SKILLS), at JOB level beside image/packages and
@@ -156,7 +161,8 @@ function routeIssueLabel(subset, triggers) {
156
161
  }
157
162
  return {
158
163
  enqueue: true,
159
- flow: rule.flow,
164
+ // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
165
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
160
166
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
161
167
  image: rule.image,
162
168
  skillsDir: rule.skillsDir,
@@ -183,15 +189,26 @@ function routeComment(subset, triggers, knownFlows) {
183
189
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
184
190
  return { enqueue: false, reason: "no-trigger-phrase" };
185
191
  }
186
- // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
187
- // known flow name, so a comment cannot summon an unlisted flow.
188
- let flow = triggers.comment?.defaultFlow;
189
- const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
190
- if (match && knownFlows?.has(match[1])) {
191
- flow = match[1];
192
- }
193
- if (flow === null || flow === undefined || flow === "") {
194
- return { enqueue: false, reason: "no-flow" };
192
+ // On a COMMAND rule (issue #189) the whole flow-resolution block below -- default flow, the `<phrase>
193
+ // <flow>` trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase
194
+ // alone fires the command, and everything after it is comment DATA (the handler can read the full
195
+ // delivery via /job/event.json), never a flow lookup. Routing trailing words through the override
196
+ // would hand any collaborator two levers this trigger's author never granted: retarget the command
197
+ // trigger onto a known flow by appending its name, or suppress it outright (the `no-flow` arm) with a
198
+ // word that resolves nowhere. Which flows exist is the operator's file's business, not the commenter's.
199
+ const command = triggers.comment.command;
200
+ let flow;
201
+ if (command === undefined) {
202
+ // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
203
+ // known flow name, so a comment cannot summon an unlisted flow.
204
+ flow = triggers.comment.defaultFlow;
205
+ const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
206
+ if (match && knownFlows?.has(match[1])) {
207
+ flow = match[1];
208
+ }
209
+ if (flow === null || flow === undefined || flow === "") {
210
+ return { enqueue: false, reason: "no-flow" };
211
+ }
195
212
  }
196
213
  // An issue_comment on a PR carries issue.pull_request; its issue.number IS the PR number and the
197
214
  // issue title/body are the PR's. Route it as a pull_request target so the flow gets PR context and
@@ -200,7 +217,7 @@ function routeComment(subset, triggers, knownFlows) {
200
217
  const isPR = subset.issue?.pull_request === true;
201
218
  return {
202
219
  enqueue: true,
203
- flow,
220
+ ...(command !== undefined ? { command } : { flow }),
204
221
  // The single comment trigger IS the matched rule here, so its opt-in is the job's. A `<phrase>
205
222
  // <flow>` override changes WHICH flow runs, never which triggers.json entry authorized it.
206
223
  packages: triggers.comment.packages,
@@ -296,7 +313,8 @@ function routePullRequest(subset, triggers, action) {
296
313
  }
297
314
  return {
298
315
  enqueue: true,
299
- flow: rule.flow,
316
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
317
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
300
318
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
301
319
  image: rule.image,
302
320
  skillsDir: rule.skillsDir,
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
- const pem = await readFile(github.privateKeyPath, "utf8");
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 GitHub webhook into (at most) one queued
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). It composes the three pieces that own the hard parts -- `makeVerifiedHandler` (the
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
- // REPLICA FANOUT (REQ-REPLICA-RUNS). The one place a single delivery becomes more than one job, and it
193
- // belongs here because this is where the 202/503 decision already lives. Absent `replicas` is `1` and
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
- for (let i = 1; i <= replicas; i++) {
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, result.job);
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", result.job);
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", result.job);
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
- process.exitCode = 1;
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
  }