@edgehero/pi-dispatch-receiver 0.2.0 → 1.0.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.0",
3
+ "version": "1.0.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.2.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
@@ -167,7 +171,8 @@ function matchLabelRules(subset, triggers, labels, action) {
167
171
  return {
168
172
  enqueue: true,
169
173
  action,
170
- flow: rule.flow,
174
+ // A command rule (issue #189) skips flow resolution entirely: the tag match IS the dispatch.
175
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
171
176
  repository: rule.repository,
172
177
  packages: rule.packages,
173
178
  image: rule.image,
@@ -186,18 +191,28 @@ function routeComment(subset, triggers, knownFlows, targetType) {
186
191
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
187
192
  return { enqueue: false, reason: "no-trigger-phrase" };
188
193
  }
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" };
194
+ // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
195
+ // trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
196
+ // fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
197
+ // active override would hand any authorized member two levers the trigger's author never granted:
198
+ // retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
199
+ // word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
200
+ const command = triggers.comment.command;
201
+ let flow;
202
+ if (command === undefined) {
203
+ flow = triggers.comment?.defaultFlow;
204
+ const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
205
+ if (match && knownFlows?.has(match[1])) {
206
+ flow = match[1];
207
+ }
208
+ if (flow === null || flow === undefined || flow === "") {
209
+ return { enqueue: false, reason: "no-flow" };
210
+ }
196
211
  }
197
212
  return {
198
213
  enqueue: true,
199
214
  action: "commented",
200
- flow,
215
+ ...(command !== undefined ? { command } : { flow }),
201
216
  repository: triggers.comment.repository,
202
217
  packages: triggers.comment.packages,
203
218
  image: triggers.comment.image,
@@ -225,7 +240,8 @@ function routePullRequest(subset, triggers, action) {
225
240
  return {
226
241
  enqueue: true,
227
242
  action,
228
- flow: rule.flow,
243
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
244
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
229
245
  repository: rule.repository,
230
246
  packages: rule.packages,
231
247
  image: rule.image,
@@ -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
@@ -136,7 +140,8 @@ function routeIssueLabel(subset, triggers) {
136
140
  }
137
141
  return {
138
142
  enqueue: true,
139
- flow: rule.flow,
143
+ // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
144
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
140
145
  packages: rule.packages, // the MATCHED rule's fields -- rules in one file may differ on them
141
146
  image: rule.image,
142
147
  skillsDir: rule.skillsDir,
@@ -154,15 +159,25 @@ function routeComment(subset, triggers, knownFlows) {
154
159
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
155
160
  return { enqueue: false, reason: "no-trigger-phrase" };
156
161
  }
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" };
162
+ // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
163
+ // trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
164
+ // fires, and trailing comment text stays DATA (the handler reads the delivery via /job/event.json). An
165
+ // active override would hand any authorized collaborator two levers the trigger's author never granted:
166
+ // retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
167
+ // word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
168
+ const command = triggers.comment.command;
169
+ let flow;
170
+ if (command === undefined) {
171
+ // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a known
172
+ // flow name, so a comment cannot summon an unlisted flow.
173
+ flow = triggers.comment?.defaultFlow;
174
+ const match = body.match(new RegExp(escapeRegExp(phrase) + "\\s+(\\S+)"));
175
+ if (match && knownFlows?.has(match[1])) {
176
+ flow = match[1];
177
+ }
178
+ if (flow === null || flow === undefined || flow === "") {
179
+ return { enqueue: false, reason: "no-flow" };
180
+ }
166
181
  }
167
182
  // `is_pull` is TOP-LEVEL on Forgejo, where GitHub carries `issue.pull_request`. Reading the wrong one
168
183
  // routes every pull-request comment as an issue, and the envelope then tells the agent to open
@@ -171,7 +186,7 @@ function routeComment(subset, triggers, knownFlows) {
171
186
  const isPR = subset.isPull === true;
172
187
  return {
173
188
  enqueue: true,
174
- flow,
189
+ ...(command !== undefined ? { command } : { flow }),
175
190
  packages: triggers.comment.packages,
176
191
  image: triggers.comment.image,
177
192
  skillsDir: triggers.comment.skillsDir,
@@ -197,7 +212,8 @@ function routePullRequest(subset, triggers, action) {
197
212
  if (rule.predicate && !matchesRule(L, rule.predicate)) continue;
198
213
  return {
199
214
  enqueue: true,
200
- flow: rule.flow,
215
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
216
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
201
217
  packages: rule.packages,
202
218
  image: rule.image,
203
219
  skillsDir: rule.skillsDir,
@@ -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
@@ -134,7 +138,8 @@ function routeLabel(subset, triggers, targetType) {
134
138
  if (!rule) return { enqueue: false, reason: "no-allowlisted-label" };
135
139
  return {
136
140
  enqueue: true,
137
- flow: rule.flow,
141
+ // A command rule (issue #189) skips flow resolution entirely: the label match IS the dispatch.
142
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
138
143
  packages: rule.packages,
139
144
  image: rule.image,
140
145
  skillsDir: rule.skillsDir,
@@ -193,7 +198,8 @@ function routeMergeRequest(subset, triggers) {
193
198
  function mrResult(subset, rule, matched) {
194
199
  return {
195
200
  enqueue: true,
196
- flow: rule.flow,
201
+ // A command rule (issue #189) skips flow resolution entirely: the rule match IS the dispatch.
202
+ ...(rule.command !== undefined ? { command: rule.command } : { flow: rule.flow }),
197
203
  packages: rule.packages,
198
204
  image: rule.image,
199
205
  skillsDir: rule.skillsDir,
@@ -218,18 +224,28 @@ function routeNote(subset, triggers, knownFlows) {
218
224
  if (typeof phrase !== "string" || typeof body !== "string" || !body.includes(phrase)) {
219
225
  return { enqueue: false, reason: "no-trigger-phrase" };
220
226
  }
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" };
227
+ // On a COMMAND rule (issue #189) the flow-resolution block below -- default flow, the `<phrase> <flow>`
228
+ // trailing-word override, and the `no-flow` refusal -- is deliberately UNREACHABLE: the phrase alone
229
+ // fires, and trailing note text stays DATA (the handler reads the delivery via /job/event.json). An
230
+ // active override would hand any authorized member two levers the trigger's author never granted:
231
+ // retarget the command onto a known flow by appending its name, or veto it (the `no-flow` arm) with a
232
+ // word that resolves nowhere. Same rationale, same shape as filter.mjs's routeComment.
233
+ const command = triggers.comment.command;
234
+ let flow;
235
+ if (command === undefined) {
236
+ // Default to the configured flow; an explicit `<phrase> <flow>` overrides only when `<flow>` is a
237
+ // known flow name, so a comment cannot summon an unlisted flow.
238
+ flow = triggers.comment?.defaultFlow;
239
+ const match = body.match(new RegExp(escapeLiteral(phrase) + "\\s+(\\S+)"));
240
+ if (match && knownFlows?.has(match[1])) flow = match[1];
241
+ if (flow === null || flow === undefined || flow === "") {
242
+ return { enqueue: false, reason: "no-flow" };
243
+ }
228
244
  }
229
245
  const targetType = subset.noteableType === "MergeRequest" ? "pull_request" : "issue";
230
246
  return {
231
247
  enqueue: true,
232
- flow,
248
+ ...(command !== undefined ? { command } : { flow }),
233
249
  packages: triggers.comment.packages,
234
250
  image: triggers.comment.image,
235
251
  skillsDir: triggers.comment.skillsDir,
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,