@edgehero/pi-dispatch 0.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.
Files changed (69) hide show
  1. package/.env.example +160 -0
  2. package/deploy/com.pi-dispatch.worker.plist +66 -0
  3. package/deploy/nssm-install.cmd +59 -0
  4. package/deploy/receiver.service +36 -0
  5. package/deploy/worker-env-wrapper.cmd +50 -0
  6. package/deploy/worker-env-wrapper.sh +63 -0
  7. package/deploy/worker.service +55 -0
  8. package/package.json +83 -0
  9. package/src/azure-auth.mjs +61 -0
  10. package/src/azure-host.mjs +236 -0
  11. package/src/azure-identity.mjs +63 -0
  12. package/src/azure-prompt.mjs +118 -0
  13. package/src/branch.mjs +80 -0
  14. package/src/budget.mjs +179 -0
  15. package/src/cli.mjs +208 -0
  16. package/src/config.mjs +329 -0
  17. package/src/connection.mjs +40 -0
  18. package/src/cron.mjs +94 -0
  19. package/src/docker-run.mjs +119 -0
  20. package/src/doctor.mjs +1127 -0
  21. package/src/env-allowlist.mjs +198 -0
  22. package/src/env-file.mjs +153 -0
  23. package/src/exit-code.mjs +32 -0
  24. package/src/flow-gate.mjs +82 -0
  25. package/src/forgejo-auth.mjs +77 -0
  26. package/src/forgejo-host.mjs +172 -0
  27. package/src/forgejo-identity.mjs +74 -0
  28. package/src/forgejo-prompt.mjs +123 -0
  29. package/src/forges.mjs +148 -0
  30. package/src/get-token.mjs +226 -0
  31. package/src/git-dirty.mjs +16 -0
  32. package/src/github-app-setup.mjs +517 -0
  33. package/src/github-host.mjs +159 -0
  34. package/src/github-prompt.mjs +286 -0
  35. package/src/gitlab-auth.mjs +72 -0
  36. package/src/gitlab-host.mjs +200 -0
  37. package/src/gitlab-identity.mjs +61 -0
  38. package/src/gitlab-prompt.mjs +123 -0
  39. package/src/identity.mjs +57 -0
  40. package/src/image-preflight.mjs +180 -0
  41. package/src/import-pi.mjs +451 -0
  42. package/src/index.mjs +177 -0
  43. package/src/init.mjs +77 -0
  44. package/src/job-id.mjs +100 -0
  45. package/src/materialize.mjs +138 -0
  46. package/src/outbox.mjs +179 -0
  47. package/src/packages.mjs +188 -0
  48. package/src/pause-windows.mjs +218 -0
  49. package/src/prepare-github.mjs +260 -0
  50. package/src/prepare-local.mjs +76 -0
  51. package/src/prepare.mjs +199 -0
  52. package/src/pricing.mjs +168 -0
  53. package/src/processor.mjs +360 -0
  54. package/src/queue.mjs +152 -0
  55. package/src/run-container.mjs +133 -0
  56. package/src/run-history.mjs +534 -0
  57. package/src/runtime-settings.mjs +188 -0
  58. package/src/sandbox-cli.mjs +156 -0
  59. package/src/sandbox-store.mjs +269 -0
  60. package/src/sandbox.mjs +171 -0
  61. package/src/scheduler-stall-guard.mjs +67 -0
  62. package/src/schedules.mjs +62 -0
  63. package/src/service.mjs +677 -0
  64. package/src/session-key.mjs +108 -0
  65. package/src/session-store.mjs +249 -0
  66. package/src/start.mjs +502 -0
  67. package/src/subscriptions.mjs +208 -0
  68. package/src/triggers.mjs +491 -0
  69. package/src/up.mjs +315 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * Shared trigger-file schema + validator (issue #20). One `triggers.json` of `{ on, run }` entries is
3
+ * the single reviewed source of standing triggers for BOTH services: the worker owns `on.type:"cron"`
4
+ * (local jobs), the receiver owns the webhook types (`label|comment|pull_request` -> a forge job). Each
5
+ * service validates the WHOLE file, then selects the `on.type` it owns, so a malformed file fails both
6
+ * identically and the two cannot drift.
7
+ *
8
+ * The on x run MATRIX is the trust boundary (DES-TRIGGERS-UNIFIED-FILE): a cron trigger carries no
9
+ * webhook delivery id, issue/PR number, title, or body, so it can only produce a `local` run; a webhook
10
+ * trigger is adversarial input and always produces a FORGE run, never a local one. Off-matrix is
11
+ * rejected fail-loud at load, exactly as the old schedules loader refused a `kind:"github"` schedule.
12
+ *
13
+ * Which forge is `run.kind` (issue #42): the `on.type` vocabulary is shared, because a label is a label
14
+ * and a comment is a comment on every forge, while the ACTION vocabulary is not -- GitHub says
15
+ * `opened`/`synchronize`, GitLab says `open`/`update`. So actions are validated against the vocabulary of
16
+ * whichever forge the entry names. That refusal matters more than it looks: an action word from the wrong
17
+ * forge does not crash anything downstream, it simply never matches an event, and the trigger silently
18
+ * never fires. Refusing it at load is what turns a silent no-op into a message.
19
+ *
20
+ * Pure and fs-free (mirrors job-id.mjs): takes the file TEXT, returns a normalized array, throws
21
+ * `configError` on any problem. Folder existence (fs-dependent) is layered on by the worker, not here.
22
+ *
23
+ * Custom: triggers validated inline per config.mjs/schedules.mjs precedent; zod not in deps
24
+ */
25
+
26
+ import { configError } from "./config.mjs";
27
+ import { FORGE_KINDS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
28
+
29
+ const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
30
+
31
+ // `RUN_KINDS` and `FORGE_KINDS` come from the forge table (forges.mjs) rather than being written out
32
+ // here. They differ by exactly `local`, and that difference IS the on x run matrix below: a webhook
33
+ // trigger produces a forge job, a cron trigger produces a local one. Re-exported because the receiver's
34
+ // config builds one trigger group per forge and a group it forgot would throw inside a reload that keeps
35
+ // the previous rules -- so the two have to be derived from one list, and a test asserts they are.
36
+ export { FORGE_KINDS };
37
+
38
+ /**
39
+ * The `pull_request` action vocabulary, per forge, in each forge's OWN words -- so an operator writes
40
+ * what their forge's documentation says and can grep for it there.
41
+ *
42
+ * GitLab has no `labeled`: adding a label to a merge request arrives as `update` carrying a
43
+ * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`. `approved` has no
44
+ * GitHub counterpart at all and is a genuinely useful gate (a member approved the MR). `merge` and
45
+ * `close` are omitted on purpose: a job started by a merge or a close has nothing left to act on.
46
+ */
47
+ const PR_ACTIONS = {
48
+ github: new Set(["labeled", "opened", "synchronize", "reopened"]),
49
+ gitlab: new Set(["open", "update", "reopen", "approved"]),
50
+ // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
51
+ // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
52
+ // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
53
+ // start a paid run, and it has no GitHub counterpart to inherit that rule from.
54
+ forgejo: new Set(["label_updated", "opened", "synchronized", "reopened"]),
55
+ // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
56
+ // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
57
+ // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
58
+ // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
59
+ azure: new Set(["created", "updated"]),
60
+ };
61
+
62
+ // A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
63
+ // parse; the charset also excludes `:` and the dedicated check names the reason.
64
+ const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
65
+
66
+ /**
67
+ * The ceiling on `run.replicas` (REQ-REPLICA-RUNS). Three, because `PI_CONCURRENCY` defaults to 3
68
+ * (config.mjs) and replicas above the default concurrency would queue rather than race -- a cap that
69
+ * promised a comparison the deployment could not deliver. A literal here rather than a read of the
70
+ * concurrency setting: this validator is pure and fs-free, and the operator who raises concurrency to 10
71
+ * is the operator who can raise this line too, in a reviewed commit.
72
+ */
73
+ const REPLICAS_MAX = 3;
74
+
75
+ function isNonEmptyString(value) {
76
+ return typeof value === "string" && value.trim() !== "";
77
+ }
78
+
79
+ /**
80
+ * Parse, validate, and normalize the unified triggers file text. Returns an array of normalized
81
+ * `{ on, run }` entries (unknown fields dropped, so consumers only ever read validated fields). Throws
82
+ * `configError` (fail-loud) on any malformed entry. The `path` is for error messages only.
83
+ */
84
+ export function parseTriggers(text, path) {
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(text);
88
+ } catch (error) {
89
+ throw configError(`triggers file is not valid JSON: ${path} (${error.message})`);
90
+ }
91
+
92
+ const entries = parsed?.triggers;
93
+ if (!Array.isArray(entries)) {
94
+ throw configError(`triggers file must have a "triggers" array: ${path}`);
95
+ }
96
+
97
+ const state = { seenCronIds: new Set(), commentCounts: {} };
98
+ return entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
99
+ }
100
+
101
+ function normalizeTrigger(entry, index, path, state) {
102
+ const at = `trigger at index ${index}`;
103
+
104
+ if (entry === null || typeof entry !== "object") {
105
+ throw configError(`${at}: must be an object: ${path}`);
106
+ }
107
+ const { on, run } = entry;
108
+ if (on === null || typeof on !== "object") {
109
+ throw configError(`${at}: "on" must be an object: ${path}`);
110
+ }
111
+ if (run === null || typeof run !== "object") {
112
+ throw configError(`${at}: "run" must be an object: ${path}`);
113
+ }
114
+ if (!ON_TYPES.has(on.type)) {
115
+ throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
116
+ }
117
+ // The legal-values half of every message below is JOINED from the table rather than typed out, so a
118
+ // forge added to the table can never be refused by a message that does not mention it -- which reads
119
+ // to an operator as a bug in their file rather than in ours.
120
+ if (!RUN_KINDS.includes(run.kind)) {
121
+ throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
122
+ }
123
+
124
+ // The on x run matrix -- the trust boundary, fail-loud (mirrors the old schedules kind:github refusal).
125
+ if (on.type === "cron") {
126
+ if (run.kind !== "local") {
127
+ throw configError(`${at}: a cron trigger has no webhook delivery, issue/PR number, title, or body; run.kind must be "local" (got ${JSON.stringify(run.kind)}): ${path}`);
128
+ }
129
+ return normalizeCron(on, run, index, path, state);
130
+ }
131
+ if (!isForgeKind(run.kind)) {
132
+ throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
133
+ }
134
+ if (on.type === "label") return normalizeLabel(on, run, index, path);
135
+ if (on.type === "comment") return normalizeComment(on, run, index, path, state);
136
+ return normalizePullRequest(on, run, index, path);
137
+ }
138
+
139
+ function normalizeCron(on, run, index, path, state) {
140
+ const at = `trigger at index ${index}`;
141
+
142
+ const id = on.id;
143
+ if (!isNonEmptyString(id)) {
144
+ throw configError(`${at}: cron on.id must be a non-empty string: ${path}`);
145
+ }
146
+ if (id.includes(":")) {
147
+ throw configError(`cron trigger "${id}": on.id must not contain ":" -- it corrupts the repeat:<id>:<millis> jobId parsing in the stall guard: ${path}`);
148
+ }
149
+ if (!ID_CHARSET.test(id)) {
150
+ throw configError(`cron trigger "${id}": on.id must match [A-Za-z0-9._-]+: ${path}`);
151
+ }
152
+ if (state.seenCronIds.has(id)) {
153
+ throw configError(`cron trigger "${id}": duplicate on.id (cron ids must be unique): ${path}`);
154
+ }
155
+ state.seenCronIds.add(id);
156
+
157
+ const pattern = on.pattern;
158
+ if (!isNonEmptyString(pattern)) {
159
+ throw configError(`cron trigger "${id}": on.pattern must be a non-empty string: ${path}`);
160
+ }
161
+ const fieldCount = pattern.trim().split(/\s+/).length;
162
+ if (fieldCount !== 5 && fieldCount !== 6) {
163
+ throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
164
+ }
165
+
166
+ if (!isNonEmptyString(run.folder)) {
167
+ throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
168
+ }
169
+ if (!isNonEmptyString(run.flow)) {
170
+ throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
171
+ }
172
+ if (!isNonEmptyString(run.task)) {
173
+ throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
174
+ }
175
+
176
+ // Cron jobs are the zero-GitHub path by default; `run.github: true` is the per-trigger opt-in that
177
+ // makes the worker mint the same scoped per-job token the github path mints, so the container can use
178
+ // the gh CLI (INT-TRIGGERS-FILE-CONTRACT). Strictly boolean, fail-loud: a truthy string like "true"
179
+ // silently opting a trigger into a credential is exactly the drift this validator exists to refuse.
180
+ if (run.github !== undefined && typeof run.github !== "boolean") {
181
+ throw configError(`cron trigger "${id}": run.github must be true or false when present: ${path}`);
182
+ }
183
+
184
+ const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
185
+ const image = validateImageRef(run, `cron trigger "${id}"`, path);
186
+ const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
187
+ // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
188
+ // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
189
+ validateReplicas(run, `cron trigger "${id}"`, path);
190
+
191
+ // provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
192
+ // settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
193
+ // absent the same way -- and that matters more for `packages` now that absent means LOAD: writing a
194
+ // `true` in here would make the schedule payload claim an opt-in the operator never wrote, and would
195
+ // freeze today's default into every stored repeatable.
196
+ return {
197
+ on: { type: "cron", id, pattern },
198
+ run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume },
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Validate the per-trigger `run.packages` flag, shared by all four normalizers. It is an opt-OUT: the pi
204
+ * packages an operator pinned into the global overlay load for every job, and `run.packages: false` is how a
205
+ * single trigger withholds them (INT-TRIGGERS-FILE-CONTRACT, REQ-GLOBAL-PI-OVERLAY). Absent and `true` both
206
+ * mean load; the default is resolved by the worker (run-container.mjs), never frozen into the file here.
207
+ *
208
+ * Still strictly boolean and still fail-loud, because the failure mode a loose parse produces has flipped
209
+ * rather than gone away: `"false"` as a string is a trigger whose operator believes it runs no third-party
210
+ * code while it loads all of it. A validator that accepted the string would make that belief undetectable.
211
+ *
212
+ * `at` is the caller's message prefix -- cron names its id, the webhook normalizers name their file index --
213
+ * so every rejection still points at the entry the operator actually wrote. Returns the flag, undefined
214
+ * when absent, so an unflagged trigger normalizes byte-identically to today's.
215
+ */
216
+ function validatePackagesFlag(run, at, path) {
217
+ if (run.packages !== undefined && typeof run.packages !== "boolean") {
218
+ throw configError(`${at}: run.packages must be true or false when present: ${path}`);
219
+ }
220
+ return run.packages;
221
+ }
222
+
223
+ /**
224
+ * Validate the per-trigger `run.resume` flag, shared by all four normalizers (REQ-RESUMABLE-SESSION).
225
+ *
226
+ * An opt-IN, and the POLARITY IS THE OPPOSITE of `run.packages` above -- deliberately, because the two
227
+ * flags gate different kinds of thing. Staging a pi package is an operator act already performed, so the
228
+ * set they staged is the set their jobs get and a trigger opts out. Persisting a session transcript is a
229
+ * DISCLOSURE: the agent's full working history -- tool output, file contents, its own reasoning -- written
230
+ * to host disk and replayed into a later job on the same key. Disclosures default off. Absent and `false`
231
+ * both mean today's behaviour, with not one byte written to disk and no /session mount in the argv.
232
+ *
233
+ * Carried on all four kinds for `run.image`'s reason rather than cron-only like `run.github`: continuing a
234
+ * conversation is a property of the FLOW, and a cron trigger's flow is a flow. A cron job keys on its own
235
+ * scheduler id (session-key.mjs), which is the one key in this feature chosen by nobody untrusted.
236
+ *
237
+ * Strictly boolean and fail-loud, the house rule -- and here the damaging misreading is a truthy `"false"`
238
+ * string, which reads to an operator as an opt-out and would arm the disclosure instead. That is the exact
239
+ * inversion validatePackagesFlag's own comment describes, arriving from the other direction.
240
+ *
241
+ * Type here, reality at job start, exactly as `run.image` splits it: this cannot know whether
242
+ * PI_SESSIONS_DIR is set, whether a key resolves, or whether a transcript exists. Those are the worker's
243
+ * to answer, and all but the first degrade to a cold start rather than refusing.
244
+ *
245
+ * `at` is the caller's message prefix. Returns the flag, undefined when absent, so an unflagged trigger
246
+ * normalizes byte-identically to today's.
247
+ */
248
+ function validateResumeFlag(run, at, path) {
249
+ if (run.resume !== undefined && typeof run.resume !== "boolean") {
250
+ throw configError(`${at}: run.resume must be true or false when present: ${path}`);
251
+ }
252
+ return run.resume;
253
+ }
254
+
255
+ /**
256
+ * Validate the per-trigger `run.image` reference, shared by all four normalizers. It selects the Docker image
257
+ * this trigger's job containers run in, overriding the deployment-wide `PI_JOB_IMAGE` for this trigger only;
258
+ * absent means the deployment default, resolved by the worker (image-preflight.mjs) and never frozen into the
259
+ * file here. Carried on all four kinds rather than cron only, for the same reason `run.packages` is: a
260
+ * toolchain is a capability of the FLOW, and a label/comment/PR trigger runs the flows a cron trigger runs.
261
+ *
262
+ * Deliberately NOT a shape check. `run.folder` -- also an operator-authored host reference -- is validated
263
+ * here as a non-empty string only, with existence deferred to the one place that can actually know; run.image
264
+ * gets exactly that split: type here, reality at job start via a pre-spend `docker image inspect`. A regex
265
+ * over the OCI reference grammar would refuse the rarer half of the problem (a malformed name) while missing
266
+ * the common half (a well-formed name for an image nobody built), and an over-strict one would refuse a
267
+ * legitimate `registry.internal:5000/team/img:1.2@sha256:...` and take the whole worker down at boot for a
268
+ * valid deployment. Docker validates its own grammar; we do not.
269
+ *
270
+ * A floating tag is likewise accepted, not warned. CONST-PI-VERSION-PINNED fears UNATTENDED drift, and that
271
+ * mechanism does not exist here: with `--pull=never` a local tag can only move when a human runs `docker
272
+ * pull` or `docker build` on this host, which is the explicit act the constraint asks for. Refusing `:latest`
273
+ * would also make `run.image: "pi-job:latest"` illegal while `PI_JOB_IMAGE=pi-job:latest` is the shipped
274
+ * default -- an incoherence an operator would rightly file as a bug.
275
+ *
276
+ * The three refusals below are not grammar. Each names a value that would corrupt something on OUR side: a
277
+ * non-string reaches `args.push(image)` and becomes a garbage argv token; an empty string is falsy and throws
278
+ * inside buildDockerRunArgs AFTER the budget slot is reserved; and a leading `-` lands in the image positional
279
+ * where docker's flag parser reads it as a flag, which is the one value that stops docker-run.mjs's
280
+ * explicit-array argv from being injection-free by inspection. Whitespace is refused rather than trimmed
281
+ * because the file is the reviewed artifact: it must not disagree with what runs.
282
+ *
283
+ * `at` is the caller's message prefix, exactly as validatePackagesFlag's is. Returns the reference, undefined
284
+ * when absent, so an unflagged trigger normalizes byte-identically to today's.
285
+ */
286
+ function validateImageRef(run, at, path) {
287
+ const image = run.image;
288
+ if (image === undefined) return undefined;
289
+ if (typeof image !== "string" || image.trim() === "") {
290
+ throw configError(`${at}: run.image must be a non-empty string when present: ${path}`);
291
+ }
292
+ if (image !== image.trim()) {
293
+ throw configError(`${at}: run.image must not have leading or trailing whitespace (got ${JSON.stringify(image)}): ${path}`);
294
+ }
295
+ if (image.startsWith("-")) {
296
+ throw configError(`${at}: run.image must not start with "-" -- it is passed as the image positional in the docker argv, where a leading dash parses as a flag (got ${JSON.stringify(image)}): ${path}`);
297
+ }
298
+ return image;
299
+ }
300
+
301
+ /**
302
+ * Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
303
+ * BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
304
+ * that reached the pure `matchesRule` in the receiver would throw there, breaking the gate's never-throw
305
+ * invariant. Returns the normalized `{any, all, none}`.
306
+ */
307
+ function validatePredicate(on, index, path, requirePositive) {
308
+ const at = `trigger at index ${index}`;
309
+ for (const key of ["any", "all", "none"]) {
310
+ const selector = on[key];
311
+ if (selector === undefined) continue;
312
+ if (!Array.isArray(selector) || selector.some((s) => typeof s !== "string" || s.trim() === "")) {
313
+ throw configError(`${at}: on.${key} must be an array of non-empty strings: ${path}`);
314
+ }
315
+ }
316
+ // A `none`-only rule matches every event lacking the excluded labels -- wider than a single-label
317
+ // allowlist, which would weaken CONST-TRIGGER-AUTHOR-GATE. Require a positive selector where the
318
+ // predicate IS the approval gate (label triggers, and `labeled` PR triggers).
319
+ if (requirePositive && (on.any?.length ?? 0) + (on.all?.length ?? 0) === 0) {
320
+ throw configError(`${at}: needs at least one positive selector (on.any or on.all): ${path}`);
321
+ }
322
+ return { any: on.any, all: on.all, none: on.none };
323
+ }
324
+
325
+
326
+ /**
327
+ * `run.repository` -- WHICH repository a job clones, for a forge whose trigger subject does not name one.
328
+ *
329
+ * Azure DevOps is the only such forge so far, and the gap is real rather than cosmetic: a work item belongs
330
+ * to a PROJECT, and a project may hold many repositories, so `workitem.updated` says nothing about where
331
+ * the agent should work. A pull request names its own repository and needs none.
332
+ *
333
+ * REQUIRED on exactly the azure trigger types a work item can fire (`label`, `comment`) and REFUSED on the
334
+ * others, rather than accepted-and-ignored. A field that is silently unused is a field an operator will set
335
+ * and then trust; refusing it is how they find out it does nothing here.
336
+ */
337
+ function validateRepository(run, onType, at, path) {
338
+ const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
339
+ const raw = run.repository;
340
+ if (raw === undefined) {
341
+ if (!needed) return undefined;
342
+ throw configError(`${at}: an azure ${onType} trigger must set run.repository -- a work item belongs to a project, not a repository, so nothing in the delivery says where to clone: ${path}`);
343
+ }
344
+ if (!needed) {
345
+ throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
346
+ }
347
+ if (!isNonEmptyString(raw) || raw !== raw.trim() || raw.includes("/")) {
348
+ throw configError(`${at}: run.repository must be a non-empty repository NAME within the project, with no slashes and no surrounding whitespace (got ${JSON.stringify(raw)}): ${path}`);
349
+ }
350
+ return raw;
351
+ }
352
+
353
+ /**
354
+ * `run.replicas` -- how many independent sandboxes race this trigger's flow (REQ-REPLICA-RUNS).
355
+ *
356
+ * The one field in this file that MULTIPLIES SPEND, so every refusal below is deliberate and none of them
357
+ * is accepted-and-ignored. It is the second kind-conditional field, after `run.repository`, and it takes
358
+ * that one's posture: a field that is silently unused is a field an operator will set and then trust.
359
+ *
360
+ * WHY EACH REFUSAL:
361
+ * - a LOCAL (cron) trigger: its `/workspace` IS the operator's folder, bind-mounted read-write and edited
362
+ * in place, so two replicas would stomp each other's working tree with no gate and no undo. A github
363
+ * job gets its own `mkdtemp`'d clone, which is the entire reason this is safe there and not here.
364
+ * Checked FIRST so a cron trigger gets that reason rather than the forge-coverage one below.
365
+ * - a non-github forge: every forge mints its branch through the same `issueBranch`, so extending this is
366
+ * mechanical -- but it is not done, and the message says "not yet covered" rather than "impossible"
367
+ * because those are different facts and an operator planning work needs the right one.
368
+ * - a non-integer, `< 2`, or `> REPLICAS_MAX`. `1` is REFUSED rather than accepted: a one-member replica
369
+ * set is a field that does nothing, and this validator's whole job is to make sure nothing does nothing.
370
+ * - `run.resume: true`. A resumed run continues ONE lineage; replicas exist to fork it. This is the
371
+ * refusal `session-key.mjs` depends on to keep calling `issueBranch` with a single argument -- without
372
+ * it every replica of an issue resolves the same session key, shares one transcript, and fights the
373
+ * store's one-writer lock. Stated in all three files, because the coupling is invisible from any one.
374
+ *
375
+ * Called from ALL FOUR normalizers -- including `normalizeCron`, which discards the result because there it
376
+ * can only ever refuse. That is `validateRepository`'s idiom and it exists for the same reason: a field
377
+ * accepted where it does nothing is how an operator comes to trust one that does nothing. Deliberately NOT
378
+ * `run.github`'s by-placement asymmetry, which lets a webhook trigger drop the field in silence.
379
+ *
380
+ * `at` is the caller's message prefix. Returns the count, undefined when absent, so an unflagged trigger
381
+ * normalizes byte-identically to today's.
382
+ */
383
+ function validateReplicas(run, at, path) {
384
+ const replicas = run.replicas;
385
+ if (replicas === undefined) return undefined;
386
+ if (run.kind === "local") {
387
+ throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
388
+ }
389
+ if (run.kind !== "github") {
390
+ throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
391
+ }
392
+ if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
393
+ throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
394
+ }
395
+ if (run.resume === true) {
396
+ throw configError(`${at}: run.replicas and run.resume cannot be combined -- a resumed run continues one lineage and replicas exist to fork it, so every replica would resolve the same session key and share one transcript: ${path}`);
397
+ }
398
+ return replicas;
399
+ }
400
+
401
+ function normalizeLabel(on, run, index, path) {
402
+ const at = `trigger at index ${index}`;
403
+ const predicate = validatePredicate(on, index, path, true);
404
+ if (!isNonEmptyString(run.flow)) {
405
+ throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
406
+ }
407
+ const packages = validatePackagesFlag(run, at, path);
408
+ const image = validateImageRef(run, at, path);
409
+ const resume = validateResumeFlag(run, at, path);
410
+ const repository = validateRepository(run, "label", at, path);
411
+ const replicas = validateReplicas(run, at, path);
412
+ return {
413
+ on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
414
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(repository !== undefined && { repository }) },
415
+ };
416
+ }
417
+
418
+ function normalizeComment(on, run, index, path, state) {
419
+ const at = `trigger at index ${index}`;
420
+ if (!isNonEmptyString(on.phrase)) {
421
+ throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
422
+ }
423
+ if (!isNonEmptyString(run.flow)) {
424
+ throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
425
+ }
426
+ // At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
427
+ // per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
428
+ // and a deployment serving GitHub and GitLab is entitled to the same `@pi` phrase on each.
429
+ state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
430
+ if (state.commentCounts[run.kind] > 1) {
431
+ throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
432
+ }
433
+ const packages = validatePackagesFlag(run, at, path);
434
+ const image = validateImageRef(run, at, path);
435
+ const resume = validateResumeFlag(run, at, path);
436
+ const repository = validateRepository(run, "comment", at, path);
437
+ const replicas = validateReplicas(run, at, path);
438
+ return {
439
+ on: { type: "comment", phrase: on.phrase },
440
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(repository !== undefined && { repository }) },
441
+ };
442
+ }
443
+
444
+ function normalizePullRequest(on, run, index, path) {
445
+ const at = `trigger at index ${index}`;
446
+
447
+ const actions = on.action;
448
+ if (!Array.isArray(actions) || actions.length === 0) {
449
+ throw configError(`${at}: pull_request on.action must be a non-empty array: ${path}`);
450
+ }
451
+ // Validated against THIS entry's forge, in that forge's own words. A GitHub word on a GitLab trigger
452
+ // (or the reverse) is refused here rather than left to never match at run time.
453
+ const allowed = PR_ACTIONS[run.kind];
454
+ const expected = [...allowed].join("|");
455
+ for (const a of actions) {
456
+ if (!allowed.has(a)) {
457
+ throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
458
+ }
459
+ }
460
+
461
+ // A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
462
+ // approval), so it MUST carry a positive selector -- exactly as a label trigger does. Auto actions
463
+ // (opened/synchronize/reopened) are gated by author_association in the filter, so a predicate is
464
+ // optional there and only narrows scope when present.
465
+ //
466
+ // WHICH word that is, and whether the forge has one at all, lives in the forge table rather than being
467
+ // tested by name here. GitLab's is null: a label added to a merge request arrives as a plain `update`,
468
+ // so there is no action for the rule to attach to. Forgejo's is `label_updated`.
469
+ const labelAction = forgeSpec(run.kind)?.prLabelAction;
470
+ const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
471
+
472
+ // Azure attaches tags to WORK ITEMS and never to pull requests, so a predicate on an azure pull_request
473
+ // rule cannot match anything. Refusing it is the same fail-loud call the action vocabulary gets: a rule
474
+ // that loads clean and can never fire reads to an operator as a harness that is broken.
475
+ if (run.kind === "azure" && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
476
+ throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
477
+ }
478
+ const predicate = validatePredicate(on, index, path, requirePositive);
479
+ if (!isNonEmptyString(run.flow)) {
480
+ throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
481
+ }
482
+ const packages = validatePackagesFlag(run, at, path);
483
+ const image = validateImageRef(run, at, path);
484
+ const resume = validateResumeFlag(run, at, path);
485
+ validateRepository(run, "pull_request", at, path);
486
+ const replicas = validateReplicas(run, at, path);
487
+ return {
488
+ on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
489
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas },
490
+ };
491
+ }