@edgehero/pi-dispatch 1.3.0 → 1.4.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.
@@ -49,7 +49,11 @@ services:
49
49
  PI_TRIGGERS_FILE: /config/triggers.json
50
50
  # The repo-root triggers.json (`pi-dispatch init` scaffolds it -- run that first: mounting a path
51
51
  # that does not exist makes Docker create it as a DIRECTORY and the boot fails confusingly).
52
- # Read-only: the receiver live-reloads this file on change; it never writes it.
52
+ # Read-only: the receiver live-reloads this file on change; it never writes it. One consequence of
53
+ # a single-FILE bind mount (issue #231): every write to this file is an atomic tmp+rename that
54
+ # SWAPS THE INODE, and the mount stays pinned to the old one -- so the worker's one-shot disarm is
55
+ # invisible in here until the container restarts, and the worker's own pre-spend check is what
56
+ # keeps a spent one-shot from running again in the meantime. A restart picks up the current file.
53
57
  volumes:
54
58
  - ../triggers.json:/config/triggers.json:ro
55
59
  # Loopback only, like Valkey's port above: the operator's reverse proxy or tunnel (TLS, public
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
6
6
  "keywords": [
@@ -52,6 +52,7 @@
52
52
  "./job-id": "./src/job-id.mjs",
53
53
  "./forges": "./src/forges.mjs",
54
54
  "./triggers": "./src/triggers.mjs",
55
+ "./triggers-file": "./src/triggers-file.mjs",
55
56
  "./packages": "./src/packages.mjs",
56
57
  "./pause-windows": "./src/pause-windows.mjs",
57
58
  "./identity": "./src/identity.mjs",
package/src/doctor.mjs CHANGED
@@ -264,7 +264,7 @@ export async function collectChecks(env, seams) {
264
264
  // image checks just below, and `optingOut`/`requiring` colour the staged-packages lines further down.
265
265
  // `optingOut` counts the only value that withholds the staged set; `requiring` counts an explicit
266
266
  // run.packages: true, which arms nothing any more but is still an operator statement of intent.
267
- const { requiring, optingOut, resuming, replicating, instructing, commands, secreting, secretProfiles, localSecretFolders, images, skillsDirs, forges, repositories, flows, parseError, path: triggersFilePath } = readTriggerFacts(env, fileExists, cwd);
267
+ const { requiring, optingOut, resuming, replicating, instructing, commands, secreting, onceArmed, onceSpent, secretProfiles, localSecretFolders, images, skillsDirs, forges, repositories, flows, parseError, path: triggersFilePath } = readTriggerFacts(env, fileExists, cwd);
268
268
  // FIRST, and fail rather than warn: every check below this line reads counts that a parse failure
269
269
  // zeroed, so a green run here would be reporting on a file nobody could read. The receiver loads this
270
270
  // file unconditionally and refuses to start without it, which is the consequence worth naming.
@@ -1242,6 +1242,31 @@ export async function collectChecks(env, seams) {
1242
1242
  });
1243
1243
  }
1244
1244
 
1245
+ // One-shot close triggers (issue #231, DES-ONE-SHOT-DISARM-IN-THE-FILE). Advisory only -- doctor
1246
+ // never touches triggers -- and counted from the RAW file (readTriggerFacts says why). Two lines
1247
+ // with different lives: the armed line names the count and, when PI_TRIGGERS_FILE is unset, warns
1248
+ // that the disarm resolves ./triggers.json against the WORKER SERVICE's working directory -- a
1249
+ // service unit whose WorkingDirectory differs from the receiver's would disarm a file nobody
1250
+ // matches against, the split-file hazard no mechanism can detect. The spent line states the
1251
+ // deliberate degradation: a spent entry counts toward NO parsed fact above (forges, flows,
1252
+ // webhook-secret), mirroring what the receiver serves at its next boot.
1253
+ if (onceArmed > 0) {
1254
+ checks.push({
1255
+ ok: true,
1256
+ warn: env.PI_TRIGGERS_FILE === undefined,
1257
+ label: `${onceArmed} one-shot trigger(s) armed (on.once) -- the worker disarms the entry in ${env.PI_TRIGGERS_FILE === undefined ? "./triggers.json resolved against the worker service's working directory; set PI_TRIGGERS_FILE so worker and receiver name the same file from anywhere" : "PI_TRIGGERS_FILE"} after the run record exists`,
1258
+ fix: "set PI_TRIGGERS_FILE to an absolute path in both services' environments",
1259
+ });
1260
+ }
1261
+ if (onceSpent > 0) {
1262
+ checks.push({
1263
+ ok: true,
1264
+ warn: false,
1265
+ label: `${onceSpent} one-shot trigger(s) already spent (on.disarmed) -- spent entries match nothing and count toward no credential or flow check; delete on.disarmed to re-arm, or delete the entry once its history no longer matters`,
1266
+ fix: "",
1267
+ });
1268
+ }
1269
+
1245
1270
  // REQ-SCOPED-PAUSE-WINDOWS, the panel-writes-what-the-worker-ignores trap (issue #99). Three defaults
1246
1271
  // that are individually defensible and together silent:
1247
1272
  //
@@ -1472,15 +1497,25 @@ function parseSecretProfilesSafe(raw) {
1472
1497
  }
1473
1498
 
1474
1499
  function readTriggerFacts(env, fileExists, cwd) {
1475
- const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, commands: 0, secreting: 0, secretProfiles: [], localSecretFolders: [], images: [], skillsDirs: [], forges: [], repositories: [], flows: [], parseError: null, path: null };
1500
+ const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, commands: 0, secreting: 0, onceArmed: 0, onceSpent: 0, secretProfiles: [], localSecretFolders: [], images: [], skillsDirs: [], forges: [], repositories: [], flows: [], parseError: null, path: null };
1476
1501
  try {
1477
1502
  // Unset falls back to ./triggers.json in cwd, MIRRORING the receiver's own default
1478
1503
  // (receiver/src/config.mjs) -- the two must read the same file, or doctor preflights a deployment
1479
1504
  // the receiver will not boot. An absent file still means "no triggers at all", exactly as before.
1480
1505
  const path = env.PI_TRIGGERS_FILE ?? join(cwd, "triggers.json");
1481
1506
  if (!fileExists(path)) return none;
1482
- const triggers = parseTriggers(readFileSync(path, "utf8"), path);
1507
+ const text = readFileSync(path, "utf8");
1508
+ const triggers = parseTriggers(text, path);
1509
+ // The one-shot facts are counted from the RAW entries, not the parsed records, because the
1510
+ // validator collapses a disarmed entry to a sentinel that carries neither `once` nor
1511
+ // `disarmed` -- exactly so nothing can match it -- which also erases it from every parsed
1512
+ // count above. Doctor is the surface that must still SEE the spent entry: "why did nothing
1513
+ // fire" is answered by a spent row, and only the raw file still holds it. Safe unguarded:
1514
+ // parseTriggers just accepted this same text, so JSON.parse cannot throw here.
1515
+ const rawEntries = JSON.parse(text)?.triggers ?? [];
1483
1516
  return {
1517
+ onceArmed: rawEntries.filter((t) => t?.on?.once === true && t.on.disarmed === undefined).length,
1518
+ onceSpent: rawEntries.filter((t) => t?.on?.disarmed !== undefined).length,
1484
1519
  requiring: triggers.filter((t) => t.run.packages === true).length,
1485
1520
  resuming: triggers.filter((t) => t.run.resume === true).length,
1486
1521
  // REQ-PER-TRIGGER-INSTRUCTION. Counted beside `resuming` for the same reason: it is a per-trigger
package/src/get-token.mjs CHANGED
@@ -104,10 +104,15 @@ export async function makeGitHubAuth(cfg, deps = {}) {
104
104
  );
105
105
  }
106
106
  const repositoryNames = [repoNameOf(repo)]; // scope to the ONE repo; owner stripped
107
+ // An optional PERMISSIONS narrowing (issue #231): the receiver's closer-permission lookup asks
108
+ // for `{ metadata: "read" }`, so the token it holds for that one question cannot write anything
109
+ // even if leaked. Job mints never pass this and keep the installation's full grant -- narrowing
110
+ // is the caller's statement of intent, not a default this mint could guess.
111
+ const permissions = job?.permissions;
107
112
  let minted;
108
113
  try {
109
114
  const appAuth = createAppAuth(auth);
110
- minted = await appAuth({ type: "installation", repositoryNames });
115
+ minted = await appAuth({ type: "installation", repositoryNames, ...(permissions && { permissions }) });
111
116
  } catch (error) {
112
117
  throw classifyAppMintError(error);
113
118
  }
package/src/index.mjs CHANGED
@@ -125,6 +125,11 @@ export function makeProcessor({ cancelJob, stopContainer, redis, getSettings, ap
125
125
  // `queueJobId`, mirroring the collectChain injection above. Omitted when unwired so a bare
126
126
  // processor keeps runJob's plain (job, token) call.
127
127
  ...(deps.prepareWorkspace ? { prepareWorkspace: (j, t) => deps.prepareWorkspace(j, t, { queueJobId: job.id }) } : {}),
128
+ // The one-shot pre-spend check (issue #231) needs the REAL BullMQ job's `.id` to excuse this
129
+ // delivery's own earlier attempt -- runJob's effectiveJob has no `.id`, prepareWorkspace's
130
+ // own injection above states why, and this one mirrors it. Omitted when unwired so a bare
131
+ // processor keeps runJob's admit-everything default.
132
+ ...(deps.checkOnceSpent ? { checkOnceSpent: (j) => deps.checkOnceSpent(j, { queueJobId: job.id }) } : {}),
128
133
  });
129
134
  recordRun({ job, result, startedAt, endedAt: new Date().toISOString() });
130
135
  return result;
package/src/processor.mjs CHANGED
@@ -42,6 +42,11 @@ export async function runJob(job, deps) {
42
42
  // this job names is on this host (image-preflight.mjs). Default admits everything, so a wiring that
43
43
  // omits it behaves exactly as before -- the container's own failure stays the backstop.
44
44
  imagePreflight = async () => ({ ok: true }),
45
+ // (job) => { ok } | { refused, at, jobId }. The one-shot pre-spend check (issue #231,
46
+ // DES-ONE-SHOT-DISARM-IN-THE-FILE). Default admits everything -- an unwired processor behaves
47
+ // exactly as before, and the gate below only calls it for a job whose matched rule was a
48
+ // one-shot, so the default is never a probe running on every delivery.
49
+ checkOnceSpent = async () => ({ ok: true }),
45
50
  // REQ-EGRESS-ALLOWLIST. Default admits everything, so a wiring that omits it behaves exactly as a
46
51
  // deployment with no egress policy does -- which is also what the real factory returns when unarmed.
47
52
  egressPreflight = async () => ({ ok: true }),
@@ -121,6 +126,27 @@ export async function runJob(job, deps) {
121
126
  let reserved = false;
122
127
 
123
128
  try {
129
+ // The one-shot pre-spend check (issue #231), FIRST on the ladder: one file read, cheaper than
130
+ // the docker inspect below, free, determinate, credential-less. Only a FOREIGN positive
131
+ // disarmed mark refuses -- the check excuses this queue job's own id, so a retry of the
132
+ // delivery that spent the trigger still runs (attempts:2 stays attempts:2) -- and anything
133
+ // unreadable or changed means "run": fail-open, because the disarm writer owns the loud
134
+ // refusals, and a broken read must never wedge every once job. In the compose topology the
135
+ // receiver reads a dead inode until restart, so this check is the once-enforcement layer
136
+ // there, not optional hardening.
137
+ if (job.trigger?.matched?.once === true) {
138
+ const spent = await checkOnceSpent(job);
139
+ if (spent.refused) {
140
+ // Commented like every sibling policy refusal: explainability is this refusal's whole
141
+ // purpose, and only a DISTINCT re-close reaches it past the GUID dedup, so the noise
142
+ // bound is the operator's own reopen-close rate. `at`/`jobId` are harness-written
143
+ // provenance, never payload text.
144
+ await comment(job, `Refused: this one-shot trigger was already spent${spent.at ? ` at ${spent.at}` : ""}${spent.jobId ? ` by job ${spent.jobId}` : ""}. The close that armed it has already produced a run; delete on.disarmed from the trigger entry to re-arm it. Not run.`);
145
+ log("refused_once_already_spent", { triggerIndex: job.trigger?.matched?.index ?? null });
146
+ return { outcome: "policy", reason: "once-already-spent", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
147
+ }
148
+ }
149
+
124
150
  // The job image must exist on THIS host before anything else happens. Free, determinate and
125
151
  // credential-less, so it precedes the mint, the clone and the reservation: a host that cannot run the
126
152
  // image refuses without minting a credential it will not use, cloning a repo it will not read, or
package/src/queue.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  import { Queue } from "bullmq";
2
2
  import { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId } from "./job-id.mjs";
3
3
  import { targetSeparator } from "./forges.mjs";
4
+ import { PR_CLOSE_ACTIONS } from "./triggers.mjs";
5
+
6
+ // The close words in every forge's spelling, derived from the one table (never re-typed here): a
7
+ // matched PR action in this set marks a close job for the semantic-key discriminant below.
8
+ const PR_CLOSE_WORDS = new Set(Object.values(PR_CLOSE_ACTIONS));
4
9
 
5
10
  export const QUEUE = "pi-jobs";
6
11
  export { chainedJobId, localJobId, deliveryJobId, gitlabDeliveryJobId, forgeDeliveryJobId };
@@ -180,6 +185,21 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
180
185
  ...(replica !== undefined && { replica }),
181
186
  ...(replicas !== undefined && { replicas }),
182
187
  };
188
+ // A close-triggered job (issue #231) leads the semantic key's flow slot with `closed:`. Without it,
189
+ // a label/comment/PR job on the same target and flow inside the 10-minute window silently swallows
190
+ // the close job -- and because a swallowed close job writes no run record, the once trigger it was
191
+ // meant to spend never disarms: a permanently dead one-shot with nothing in the panel to say why.
192
+ // The discriminant is DERIVED from the matched rule (`issue` type, or a PR close action word) rather
193
+ // than carried as a job field: an execution detail of dedup is not a fact about the delivery, and
194
+ // `data`/`event.json` stay byte-identical. `:` is outside the skill-name charset -- enforced at load
195
+ // since #231 -- so no real flow can spell either prefixed form, and `closed:cmd:<name>` composes for
196
+ // close-dispatched commands (outermost discriminant first, then the entry-point prefix).
197
+ const matched = trigger?.matched;
198
+ // `type === "issue"` reads as "close" only while the issue vocabulary is close-only (it is; the
199
+ // tables say "one word each so far"). If that type ever grows a non-close action, this test must
200
+ // narrow to the matched action word, like the PR half already does.
201
+ const isCloseJob = matched?.type === "issue" || (matched?.type === "pull_request" && PR_CLOSE_WORDS.has(matched?.action));
202
+ const flowSlot = `${isCloseJob ? "closed:" : ""}${command !== undefined ? `cmd:${command}` : flow}`;
183
203
  await queue.add(kind, data, {
184
204
  jobId,
185
205
  // A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
@@ -188,7 +208,7 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
188
208
  // `cmd:` prefix keeps a command named X from coalescing against a flow named X -- `:` is outside
189
209
  // the skill-name charset, so no real flow can spell the prefixed form -- and a flow job's key
190
210
  // stays byte-identical to before the feature.
191
- deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${command !== undefined ? `cmd:${command}` : flow}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
211
+ deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${flowSlot}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
192
212
  attempts: 2,
193
213
  backoff: { type: "exponential", delay: 60_000 },
194
214
  removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
@@ -34,13 +34,57 @@ export function sanitizeJobId(id) {
34
34
  return s.replace(/[^A-Za-z0-9._-]/g, "_");
35
35
  }
36
36
 
37
+ /**
38
+ * Parse one line of the buffered tail as a runner-emitted JSON line, or `null` for noise -- repairing
39
+ * the GLUED case (issue #224, OQ-003).
40
+ *
41
+ * The hazard: the runner's writes used to be newline-terminated but not newline-delimited, so a
42
+ * partial write from anything sharing the container's stdout (a subprocess that never flushed its
43
+ * trailing newline) lands as `<stray bytes><runner line>` in ONE line. A plain JSON.parse skips it,
44
+ * and because every parseExit* scan walks backwards past what does not parse, one un-newlined byte
45
+ * used to lose turns, tokens, usage, session and context at once -- or worse, hand the scan to a
46
+ * forged exit line placed earlier.
47
+ *
48
+ * The repair re-anchors on `{"event":"`, which is collision-free by construction: `event` is the
49
+ * first key both runner writers serialise, and JSON.stringify escapes every quote inside a string
50
+ * value, so these raw bytes cannot occur INSIDE a runner line -- only where one starts. Suffixes are
51
+ * tried left to right, so the leftmost complete object wins: a suffix beginning inside the stray
52
+ * bytes cannot parse to the line's end (nothing can close a JSON container after bytes the runner
53
+ * appended later, and the runner's own quotes terminate any string opened before them), so the first
54
+ * success is the glued runner object itself -- and this left-to-right scan, not just the quote
55
+ * escaping, is what keeps a would-be inner `{"event":"` from being chosen over the outer one.
56
+ * A line whose HEAD the capped tail sliced off is handled correctly either way: if the cut fell in a
57
+ * glued line's stray PREFIX the anchor survives and the genuine object is still repaired, and if it
58
+ * fell in or past the anchor no `{"event":"` survives and the line is skipped. A line truncated at the
59
+ * END (a mid-write death) has no complete object and is skipped too. A fragment is never MISREAD as a
60
+ * value: a broken one does not parse and an anchorless one is not repaired.
61
+ *
62
+ * NEVER throws, like the five scanners that call it.
63
+ */
64
+ function parseTailLine(line) {
65
+ try {
66
+ return JSON.parse(line);
67
+ } catch {
68
+ // docker/agent noise, a truncated line, or a glued one -- try the repair before giving up.
69
+ }
70
+ let from = line.indexOf('{"event":"', 1);
71
+ while (from !== -1) {
72
+ try {
73
+ return JSON.parse(line.slice(from));
74
+ } catch {
75
+ from = line.indexOf('{"event":"', from + 1);
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
37
81
  /**
38
82
  * Recover the agent's turn count from buffered container stdout, or `null` if it is not reported.
39
83
  *
40
84
  * The stream interleaves docker/agent noise and other JSON events (`pi_auto_retry`) with the runner's
41
85
  * own lines. Only the success exit line carries `turns` (`image/runner/run-job.mjs:263`); the
42
86
  * catch-path exit line (`:277`) omits it. Scan from the end and return the turns of the last `exit`
43
- * event that reports an integer count.
87
+ * event that reports an integer count, repairing a glued line on the way (`parseTailLine`).
44
88
  *
45
89
  * This is read-only telemetry: it MUST NEVER throw and MUST NOT feed exit-code or retry
46
90
  * classification -- that is the container exit code's job (INT-RUNNER-EXIT-CODE-PROTOCOL). Every parse
@@ -52,12 +96,7 @@ export function parseExitTurns(text) {
52
96
  for (let i = lines.length - 1; i >= 0; i--) {
53
97
  const line = lines[i].trim();
54
98
  if (line === "") continue;
55
- let parsed;
56
- try {
57
- parsed = JSON.parse(line);
58
- } catch {
59
- continue; // docker/agent noise or a truncated final line
60
- }
99
+ const parsed = parseTailLine(line);
61
100
  if (parsed?.event !== "exit") continue;
62
101
  return Number.isInteger(parsed?.turns) ? parsed.turns : null;
63
102
  }
@@ -94,12 +133,7 @@ export function parseExitSession(text) {
94
133
  for (let i = lines.length - 1; i >= 0; i--) {
95
134
  const line = lines[i].trim();
96
135
  if (line === "") continue;
97
- let parsed;
98
- try {
99
- parsed = JSON.parse(line);
100
- } catch {
101
- continue; // docker/agent noise or a truncated final line
102
- }
136
+ const parsed = parseTailLine(line);
103
137
  if (parsed?.event !== "exit") continue;
104
138
  const sess = parsed?.session;
105
139
  if (sess && typeof sess === "object" && !Array.isArray(sess) && typeof sess.resumed === "boolean") {
@@ -126,12 +160,7 @@ export function parseExitContext(text) {
126
160
  for (let i = lines.length - 1; i >= 0; i--) {
127
161
  const line = lines[i].trim();
128
162
  if (line === "") continue;
129
- let parsed;
130
- try {
131
- parsed = JSON.parse(line);
132
- } catch {
133
- continue; // docker/agent noise or a truncated final line
134
- }
163
+ const parsed = parseTailLine(line);
135
164
  if (parsed?.event !== "exit") continue;
136
165
  const c = parsed?.context;
137
166
  // A window of 0 is not a denominator, and a negative count is not a measurement. SAFE integers
@@ -153,12 +182,7 @@ export function parseExitTokens(text) {
153
182
  for (let i = lines.length - 1; i >= 0; i--) {
154
183
  const line = lines[i].trim();
155
184
  if (line === "") continue;
156
- let parsed;
157
- try {
158
- parsed = JSON.parse(line);
159
- } catch {
160
- continue; // docker/agent noise or a truncated final line
161
- }
185
+ const parsed = parseTailLine(line);
162
186
  if (parsed?.event !== "exit") continue;
163
187
  const t = parsed?.tokens;
164
188
  if (t && typeof t === "object" && !Array.isArray(t) && typeof t.total === "number") return t;
@@ -204,12 +228,7 @@ export function parseExitUsage(text) {
204
228
  for (let i = lines.length - 1; i >= 0; i--) {
205
229
  const line = lines[i].trim();
206
230
  if (line === "") continue;
207
- let parsed;
208
- try {
209
- parsed = JSON.parse(line);
210
- } catch {
211
- continue; // docker/agent noise or a truncated final line
212
- }
231
+ const parsed = parseTailLine(line);
213
232
  if (parsed?.event !== "exit") continue;
214
233
  return rebuildUsage(parsed?.usage);
215
234
  }
package/src/start.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { watch } from "node:fs";
3
- import { dirname, basename } from "node:path";
3
+ import { dirname, basename, join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { configError, loadConfig } from "./config.mjs";
6
6
  import { makeRedisClient, parseConnection } from "./connection.mjs";
@@ -22,6 +22,7 @@ import { makeCleanup, makeForgePreparers, makePrepareWorkspace } from "./prepare
22
22
  import { listRunningSandboxes } from "./sandbox.mjs";
23
23
  import { makeSandboxReaper } from "./sandbox-store.mjs";
24
24
  import { makeSessionStore } from "./session-store.mjs";
25
+ import { makeCheckOnceSpent, makeDisarmOnce } from "./triggers-file.mjs";
25
26
  import { loadPauseWindows, pauseUntilMs } from "./pause-windows.mjs";
26
27
  import { makeQueue } from "./queue.mjs";
27
28
  import { makeRunContainer } from "./run-container.mjs";
@@ -313,8 +314,25 @@ export async function startWorker(
313
314
  } catch (err) {
314
315
  log("session_reaper_skipped", { reason: err?.message });
315
316
  }
316
- const recordRun = ({ job, result, error, startedAt, endedAt }) =>
317
+ // The one-shot file path (issue #231): PI_TRIGGERS_FILE, else ./triggers.json against this process's
318
+ // cwd -- doctor's own fallback, chosen for doctor's own reason ("the two must read the same file"),
319
+ // and deliberately NOT config.triggersFile, whose null means "cron disabled" and must keep meaning
320
+ // that: under that knob the DEFAULT single-host deployment would have a firing receiver and a worker
321
+ // that can neither disarm nor pre-spend-check.
322
+ const onceTriggersFile = env.PI_TRIGGERS_FILE ?? join(process.cwd(), "triggers.json");
323
+ const disarmOnce = makeDisarmOnce({ triggersPath: onceTriggersFile, log });
324
+ const recordRun = ({ job, result, error, startedAt, endedAt }) => {
317
325
  writeRecord(buildRecord({ job, result, error, startedAt, endedAt }));
326
+ // Strictly AFTER the durable record: "fired" means "produced a run record", and the crash
327
+ // direction this ordering buys is the chosen one -- an armed one-shot with a record, never a
328
+ // disarm before writeRecord RETURNED. Returned, not succeeded: the record writer swallows fs
329
+ // errors by contract (run_record_failed), so a full disk still spends the one-shot -- the
330
+ // alternative, skipping the disarm on a failed record write, would re-fire it unbounded. Fire-and-forget: the hook never rejects, and the record path must not
331
+ // wait on a lock retry. An uncontended disarm completes synchronously inside this call; the
332
+ // one loss window is a drain's process.exit landing mid-lock-retry sleep, which loses only
333
+ // the disarm -- the same chosen direction, met at shutdown instead of a crash.
334
+ void disarmOnce({ job, endedAt });
335
+ };
318
336
 
319
337
  // INT-CONFIG-OVERLAY-CONTRACT: the worker reads the runtime-settings overlay at EACH job start, so this
320
338
  // closure -- not a value frozen at boot -- is what the processor calls per job. It resolves the eight
@@ -406,6 +424,11 @@ export async function startWorker(
406
424
  pauseUntil: (job, now) => pauseUntilMs(pauseWindows.current, job, now),
407
425
  deps: {
408
426
  collectChain,
427
+ // The one-shot pre-spend check (issue #231): reads the same file the disarm writes, refuses
428
+ // only on a FOREIGN positive mark (index.mjs binds the real queue jobId so a retry of the
429
+ // spending delivery is excused). In the compose topology this check is the once-enforcement
430
+ // layer, because the receiver's single-file :ro mount pins a dead inode until restart.
431
+ checkOnceSpent: makeCheckOnceSpent({ triggersPath: onceTriggersFile }),
409
432
  // One deployment default, two consumers, adjacent by construction: the preflight that refuses a missing
410
433
  // image BEFORE the budget slot, and the factory that puts it in the argv. Both resolve a trigger's own
411
434
  // `run.image` through the same resolveJobImage, so the image that was checked is the image that runs.
@@ -0,0 +1,403 @@
1
+ /**
2
+ * The shared triggers-file WRITER (issue #231, DES-ONE-SHOT-DISARM-IN-THE-FILE, OQ-008).
3
+ *
4
+ * Moved here from the admin's read-model so BOTH authors of `triggers.json` serialize through one
5
+ * funnel: the operator's console (dialogs and confirm-gated tools, via the admin's re-export) and the
6
+ * worker's one-shot disarm. "REUSE, NEVER RE-DERIVE" -- the same rule that single-sources the parser
7
+ * and `loadGitHubAuth`. The file format is the admin's own: 2-space JSON plus a trailing newline,
8
+ * byte-for-byte what `writeTriggers` always wrote, and a test pins it.
9
+ *
10
+ * THE LOCK. Until #231 there was exactly one writer (one single-threaded pi process, tools declared
11
+ * sequential), so read-modify-write had nothing to race and `renameSync`'s last-writer-wins was moot.
12
+ * The worker's disarm is a second author -- and at PI_CONCURRENCY up to 3, a third and fourth -- so
13
+ * every write now takes `<path>.lock` via exclusive create (`wx`), the session-store's idiom with its
14
+ * two doctrines kept verbatim: EEXIST is the ONLY failure that means locked (anything else failed to
15
+ * create the lock for its own reason and is reported as that reason), and a leaked lock is logged,
16
+ * never thrown. What is NEW here, with no in-repo precedent, is the STALE TAKEOVER: a lock whose
17
+ * mtime is older than LOCK_STALE_MS is unlinked and retaken once. The session store can afford to
18
+ * discard on contention and let its reaper sweep a leak; this file cannot -- a crashed writer's lock
19
+ * would otherwise wedge every trigger add, edit, delete and disarm on the deployment forever, and
20
+ * there is no reaper whose beat covers it. The residual is the classic one: unlink-then-create is not
21
+ * atomic, so two writers racing a stale takeover can interleave in a window of milliseconds. That
22
+ * window replaces today's always-open one, and the loser's write still validated through the shared
23
+ * parser, so the file stays loadable; the lost update is one disarm or one edit, and the disarm
24
+ * caller retries.
25
+ *
26
+ * Callers split by posture, deliberately:
27
+ * - `writeTriggers` is SYNC and gives up IMMEDIATELY on contention (`{ invalid }` naming the lock).
28
+ * Its callers sit on the pi TUI's event loop, where a bounded-retry sleep is a frozen panel; an
29
+ * operator whose keypress lost the race gets a message and presses the key again.
30
+ * - `disarmTrigger` is ASYNC and retries with jitter, because ITS caller is the worker's post-run
31
+ * hook with nobody at the keyboard, and the thing it races (an operator edit, a sibling job's
32
+ * disarm) clears in milliseconds.
33
+ *
34
+ * `disarmTrigger` is also deliberately NARROWER than `writeTriggers`: it refuses an unreadable file
35
+ * outright rather than repairing from empty. The repair posture is right for the operator CRUD path
36
+ * (a missing file plus "add trigger" should scaffold) and catastrophic here -- overwriting a file the
37
+ * worker could not read, to record one disarm, would destroy the operator's trigger set.
38
+ *
39
+ * Custom: exclusive-create lockfile per session-store.mjs precedent; no proper-lockfile dependency
40
+ * (repo keeps runtime deps minimal, and the two-writer case needs no lease/renewal machinery)
41
+ */
42
+
43
+ import nodeFs from "node:fs";
44
+ import { parseTriggers } from "./triggers.mjs";
45
+
46
+ /**
47
+ * A lock older than this is a crashed writer's, not a live one's: every write under it is a read,
48
+ * one mutate, one serialize and two syscalls, three orders of magnitude faster. Ten seconds rather
49
+ * than one so a laptop suspending mid-write on battery does not get its live lock stolen on resume.
50
+ */
51
+ const LOCK_STALE_MS = 10_000;
52
+
53
+ /** Bounded contention retry for the disarm path: ~5 attempts x 100-300ms jitter, well under a second of
54
+ * real contention, and the whole wait is smaller than the dedup window that bounds what a lost disarm
55
+ * costs. */
56
+ const DISARM_LOCK_ATTEMPTS = 5;
57
+
58
+ function lockPathFor(triggersPath) {
59
+ return `${triggersPath}.lock`;
60
+ }
61
+
62
+ /**
63
+ * Take the lock, with one stale takeover. Returns an fd, or null when a LIVE writer holds it.
64
+ * Throws only for non-EEXIST failures -- the session-store doctrine: reporting a read-only dir or a
65
+ * full disk as "locked" sends an operator hunting for a stuck lock file that does not exist.
66
+ */
67
+ function takeLock(triggersPath, fs, log) {
68
+ const lock = lockPathFor(triggersPath);
69
+ let sweptAgeMs = null;
70
+ for (let attempt = 0; attempt < 2; attempt++) {
71
+ try {
72
+ const fd = fs.openSync(lock, "wx"); // exclusive create IS the lock; no daemon, no lease
73
+ // Logged only AFTER the retake create succeeded: the unlink alone proves nothing (a rival
74
+ // sweeper may win the recreate race), and a takeover log for a lock we did not get would
75
+ // send an operator reading a history that never happened.
76
+ if (sweptAgeMs !== null) log("triggers_lock_stale_taken", { ageMs: sweptAgeMs });
77
+ return fd;
78
+ } catch (err) {
79
+ if (err?.code !== "EEXIST") throw err;
80
+ let mtimeMs;
81
+ try {
82
+ mtimeMs = fs.statSync(lock).mtimeMs;
83
+ } catch {
84
+ // The holder released between our open and our stat: the next loop iteration takes it.
85
+ continue;
86
+ }
87
+ if (Date.now() - mtimeMs <= LOCK_STALE_MS) return null; // live writer; caller decides
88
+ try {
89
+ fs.unlinkSync(lock);
90
+ } catch {
91
+ // Someone else swept it first; the retry create answers who won.
92
+ }
93
+ sweptAgeMs = Math.round(Date.now() - mtimeMs);
94
+ }
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function releaseLock(fd, triggersPath, fs, log) {
100
+ fs.closeSync(fd);
101
+ try {
102
+ fs.unlinkSync(lockPathFor(triggersPath));
103
+ } catch {
104
+ // A leaked lock delays writers by LOCK_STALE_MS, then the takeover clears it. Logged, never
105
+ // thrown -- the session-store rule.
106
+ log("triggers_lock_stuck", {});
107
+ }
108
+ }
109
+
110
+ /** The one serializer: 2-space plus trailing newline, byte-identical to what the admin always wrote --
111
+ * both live-reload watchers and the operator's own diff read this file, so its shape is a contract. */
112
+ function serialize(triggersArray) {
113
+ return `${JSON.stringify({ triggers: triggersArray }, null, 2)}\n`;
114
+ }
115
+
116
+ /** tmp + rename with writeOverlay's single EPERM retry (a Windows AV/indexer briefly holding the
117
+ * destination); a second EPERM, and every other fs failure, propagates to the caller's contract. */
118
+ function renameIntoPlace(fs, tmp, dest) {
119
+ try {
120
+ fs.renameSync(tmp, dest);
121
+ } catch (err) {
122
+ if (err?.code !== "EPERM") throw err;
123
+ fs.renameSync(tmp, dest); // single retry: the AV/indexer lock is transient
124
+ }
125
+ }
126
+
127
+ let tmpSeq = 0;
128
+
129
+ /**
130
+ * A PER-WRITER tmp name, not the fixed `.tmp` the admin writer used to share. With one writer the
131
+ * fixed name was self-cleaning and harmless; with two authors it quietly voided the atomicity claim
132
+ * in the one window the lock concedes (the stale-takeover double-take): two writers sharing one tmp
133
+ * path means B can rename A's half-flushed tmp over the destination, and "a watcher never observes a
134
+ * half-written file" stops being true precisely when it matters. A pid+sequence name gives each
135
+ * write its own inode, so the rename is atomic no matter who else is mid-write. The cost is that a
136
+ * crash between write and rename leaves a uniquely-named straggler instead of one that the next
137
+ * write overwrites -- so both writers unlink their tmp on the failure path.
138
+ */
139
+ function tmpPathFor(triggersPath) {
140
+ return `${triggersPath}.${process.pid}.${tmpSeq++}.tmp`;
141
+ }
142
+
143
+ /** Best-effort cleanup of this writer's own tmp after a failed write; the file either renamed away
144
+ * (unlink finds nothing, fine) or must not be left as litter. Never throws over the real failure. */
145
+ function discardTmp(fs, tmp) {
146
+ try {
147
+ fs.unlinkSync(tmp);
148
+ } catch {
149
+ // Already renamed, or never written: either way there is nothing to clean.
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Read-modify-write the triggers file under the lock. `mutate` receives the RAW entries (shallow
155
+ * copies) and returns the next raw array; the result is validated through the loaders' own
156
+ * `parseTriggers` -- never write a file they would reject -- and written atomically (tmp + rename) so
157
+ * a live-reload watcher never observes a half-written file.
158
+ *
159
+ * Human-approved writes only on the console path: reached from the operator-typed `/dispatch trigger
160
+ * ...` handlers and from the `dispatch_trigger_*` tools behind `confirmedWrite`'s dialog, so the
161
+ * human keypress is the approval and CONST-TRIGGER-AUTHOR-GATE's principle holds. The worker's disarm
162
+ * does NOT come through here -- `disarmTrigger` below is its own, narrower entry.
163
+ *
164
+ * A missing or unparseable existing file starts from an empty set; the validated write repairs it.
165
+ * Returns `{ ok: true }`, or `{ invalid }` for a validation failure OR a held lock; fs failures throw,
166
+ * the contract this function has always had.
167
+ */
168
+ export function writeTriggers({ triggersPath, mutate, fs = nodeFs, log = () => {} }) {
169
+ const fd = takeLock(triggersPath, fs, log);
170
+ if (fd === null) {
171
+ // Immediate, not retried: the callers sit on the pi TUI event loop, and the holder is a write
172
+ // that finishes in milliseconds. The operator re-presses; the file was never touched.
173
+ return { invalid: `triggers file locked (another write in progress): ${lockPathFor(triggersPath)}` };
174
+ }
175
+ try {
176
+ let current = [];
177
+ try {
178
+ const raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
179
+ if (Array.isArray(raw?.triggers)) current = raw.triggers;
180
+ } catch {
181
+ // Missing/invalid file: start from empty; the validated atomic write below repairs it.
182
+ }
183
+ const next = mutate(current.map((t) => ({ ...t })));
184
+ const text = serialize(next);
185
+ try {
186
+ parseTriggers(text, triggersPath); // the loaders' own validator -- never write a file they would reject
187
+ } catch (e) {
188
+ return { invalid: e?.message ?? String(e) };
189
+ }
190
+ const tmp = tmpPathFor(triggersPath);
191
+ try {
192
+ fs.writeFileSync(tmp, text, { mode: 0o644 });
193
+ renameIntoPlace(fs, tmp, triggersPath);
194
+ } catch (err) {
195
+ discardTmp(fs, tmp);
196
+ throw err; // the writer's contract: fs failures throw
197
+ }
198
+ return { ok: true };
199
+ } finally {
200
+ releaseLock(fd, triggersPath, fs, log);
201
+ }
202
+ }
203
+
204
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
205
+
206
+ /**
207
+ * Disarm ONE spent one-shot: add `on.disarmed = { at, jobId }` to the entry at `index`, and nothing
208
+ * else -- the worker's whole write authority over this file is this one added key, which is what lets
209
+ * OQ-008's "the file is the single write target" survive a second author (the worker can disarm what
210
+ * an operator armed; no machine path can ARM anything).
211
+ *
212
+ * The identity check compares EVERY field the job knows about the trigger it matched: `index` is
213
+ * positional and the file can change between enqueue and disarm, so the entry must still be an armed
214
+ * one-shot naming the exact item (`number`) and dispatching the exact `flow` or `command` the job
215
+ * carried. What the check confirms is therefore the matched ITEM and TARGET, not the trigger
216
+ * INSTANCE: an operator who deletes a spent-in-flight one-shot and re-arms an IDENTICAL one (same
217
+ * index, same number, same flow) inside the job's own run window has re-armed something this writer
218
+ * cannot tell from the original, and the earlier job's disarm will spend it. That residual is
219
+ * named rather than closed because a per-trigger id is the thing the design rejects -- the raw index
220
+ * IS the identity (`INT-TRIGGERS-FILE-CONTRACT`) -- and every DIFFERING re-arm (other flow, other
221
+ * number, other shape) refuses loudly here.
222
+ *
223
+ * Returns `{ ok }`, `{ already }` (a sibling replica/redelivery won the race -- idempotent success,
224
+ * not failure), or `{ invalid }` with an operator-actionable reason. NEVER throws, and NEVER repairs:
225
+ * an unreadable file is `{ invalid }` with the bytes untouched.
226
+ */
227
+ export async function disarmTrigger({ triggersPath, index, number, flow, command, jobId, at, fs = nodeFs, log = () => {} }) {
228
+ for (let attempt = 0; attempt < DISARM_LOCK_ATTEMPTS; attempt++) {
229
+ let fd;
230
+ try {
231
+ fd = takeLock(triggersPath, fs, log);
232
+ } catch (err) {
233
+ return { invalid: `triggers file lock failed (${err?.code ?? "lock-error"}): ${triggersPath}` };
234
+ }
235
+ if (fd === null) {
236
+ await sleep(100 + Math.floor(Math.random() * 200));
237
+ continue;
238
+ }
239
+ try {
240
+ let raw;
241
+ try {
242
+ raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
243
+ } catch (err) {
244
+ // NEVER repair-from-empty here: overwriting a file we could not read, to record one
245
+ // disarm, would destroy the operator's trigger set. writeTriggers' repair posture is for
246
+ // the operator CRUD path, where "missing" means "first trigger".
247
+ return { invalid: `triggers file unreadable (${err?.code ?? "parse-error"}), disarm not written: ${triggersPath}` };
248
+ }
249
+ const entries = Array.isArray(raw?.triggers) ? raw.triggers : null;
250
+ const entry = entries?.[index];
251
+ if (!entry || typeof entry !== "object") {
252
+ return { invalid: `no trigger at index ${index} -- the file changed since this job matched; not disarming a stranger` };
253
+ }
254
+ if (entry.on?.once !== true) {
255
+ return { invalid: `trigger at index ${index} is not an armed one-shot -- the file changed since this job matched; not disarming a stranger` };
256
+ }
257
+ if (entry.on?.number !== number) {
258
+ return { invalid: `trigger at index ${index} names item #${entry.on?.number}, this job matched #${number} -- a re-attributed index refuses rather than disarming a stranger` };
259
+ }
260
+ // Compared only when the caller supplied one, and BOTH lanes checked when it did: a job
261
+ // carries exactly one of flow/command, and the entry must agree on which lane as well as
262
+ // the name -- a flow job must not spend a command one-shot that took the same slot.
263
+ if (flow !== undefined && (entry.run?.flow !== flow || entry.run?.command !== undefined)) {
264
+ return { invalid: `trigger at index ${index} does not dispatch flow ${JSON.stringify(flow)} -- the entry changed since this job matched; not disarming a stranger` };
265
+ }
266
+ if (command !== undefined && (entry.run?.command !== command || entry.run?.flow !== undefined)) {
267
+ return { invalid: `trigger at index ${index} does not dispatch command ${JSON.stringify(command)} -- the entry changed since this job matched; not disarming a stranger` };
268
+ }
269
+ if (entry.on.disarmed !== undefined) {
270
+ return { already: true };
271
+ }
272
+ entry.on.disarmed = { at, ...(jobId !== undefined && { jobId }) };
273
+ const text = serialize(entries);
274
+ try {
275
+ parseTriggers(text, triggersPath); // the shared validator, same rule as every write
276
+ } catch (e) {
277
+ return { invalid: e?.message ?? String(e) };
278
+ }
279
+ const tmp = tmpPathFor(triggersPath);
280
+ try {
281
+ fs.writeFileSync(tmp, text, { mode: 0o644 });
282
+ renameIntoPlace(fs, tmp, triggersPath);
283
+ } catch (err) {
284
+ discardTmp(fs, tmp);
285
+ return { invalid: `triggers file write failed (${err?.code ?? "write-error"}): ${triggersPath}` };
286
+ }
287
+ return { ok: true };
288
+ } finally {
289
+ releaseLock(fd, triggersPath, fs, log);
290
+ }
291
+ }
292
+ return { invalid: `triggers file locked after ${DISARM_LOCK_ATTEMPTS} attempts: ${lockPathFor(triggersPath)}` };
293
+ }
294
+
295
+ /**
296
+ * The pre-spend read (worker slice of #231): what does the FILE currently say about the one-shot at
297
+ * `index`? Fail-open by design -- the caller refuses a job only on POSITIVE disarmed evidence, so
298
+ * "unknown" (unreadable file, index gone, entry no longer a one-shot) means "run": a broken read must
299
+ * never wedge every once job, and the identity mismatch cases are the disarm writer's to refuse.
300
+ */
301
+ /**
302
+ * The post-record disarm hook (issue #231): wired around the worker's one recordRun funnel, called
303
+ * strictly AFTER writeRecord returns, for EVERY record -- completed, policy, and per-attempt failed
304
+ * alike, because "fired" means "produced a run record" (the issue's own definition) and the
305
+ * pre-spend check's own-jobId exception is what keeps BullMQ's second attempt of the same delivery
306
+ * runnable. NEVER throws and never rejects: a disarm failure is a loud log line, not a crashed
307
+ * record path.
308
+ *
309
+ * `triggersPath` may be null only when even the cwd fallback could not be formed; the caller
310
+ * resolves `PI_TRIGGERS_FILE ?? join(cwd, "triggers.json")` -- doctor's own precedent, NOT the
311
+ * worker config's `triggersFile` (whose null means "cron disabled" and must keep meaning that;
312
+ * under that knob the DEFAULT single-host deployment would have a firing receiver and a worker
313
+ * that can neither disarm nor pre-spend-check).
314
+ */
315
+ export function makeDisarmOnce({ triggersPath, fs = nodeFs, log = () => {}, disarm = disarmTrigger }) {
316
+ return async function disarmOnce({ job, endedAt }) {
317
+ try {
318
+ const matched = job?.data?.trigger?.matched;
319
+ if (matched?.once !== true) return; // every unflagged job takes zero new code paths
320
+ const triggerIndex = matched.index ?? null;
321
+ if (typeof triggersPath !== "string" || triggersPath === "") {
322
+ log("trigger_disarm_unavailable", { jobId: job?.id ?? null, triggerIndex, reason: "triggers file unresolvable" });
323
+ return;
324
+ }
325
+ // The identity the writer re-checks: the item number (the issue shape carries it on matched,
326
+ // the PR shape on the target) and the dispatch lane the job actually carried.
327
+ const number = matched.number ?? job?.data?.target?.number;
328
+ const res = await disarm({
329
+ triggersPath,
330
+ index: matched.index,
331
+ number,
332
+ flow: job?.data?.flow,
333
+ command: job?.data?.command,
334
+ jobId: job?.id,
335
+ at: endedAt,
336
+ fs,
337
+ log,
338
+ });
339
+ if (res.ok) log("trigger_disarmed", { jobId: job?.id ?? null, triggerIndex });
340
+ else if (res.already) log("trigger_already_disarmed", { jobId: job?.id ?? null, triggerIndex });
341
+ else log("trigger_disarm_failed", { jobId: job?.id ?? null, triggerIndex, reason: res.invalid });
342
+ } catch (err) {
343
+ // Unreachable by construction (disarmTrigger never throws), kept because this hook sits on
344
+ // the record path and a record must never be lost to bookkeeping.
345
+ log("trigger_disarm_failed", { jobId: job?.id ?? null, triggerIndex: job?.data?.trigger?.matched?.index ?? null, reason: err?.code ?? "disarm-error" });
346
+ }
347
+ };
348
+ }
349
+
350
+ /**
351
+ * The pre-spend check's factory (issue #231): `(job, { queueJobId }) => { ok } | { refused, at, jobId }`.
352
+ * Refuses ONLY on positive FOREIGN disarmed evidence -- a mark whose jobId is this very queue job
353
+ * means BullMQ's second attempt of the delivery that spent the trigger, which must still run
354
+ * (without the exception, a disarm on attempt one's failure record silently turns attempts:2 into
355
+ * attempts:1 for every once job). A hand-written mark carries no jobId and reads as foreign, which
356
+ * is exactly what an operator disarming by hand intends. Everything else -- unreadable file, index
357
+ * gone, entry changed -- is "run": fail-open, the disarm writer owns the loud refusals, and in the
358
+ * compose topology (single-file :ro bind mount pinned to a dead inode, so the receiver never sees
359
+ * the disarm until restart) this check IS the once-enforcement layer, which is why it exists at all.
360
+ */
361
+ export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
362
+ return async function checkOnceSpent(job, { queueJobId } = {}) {
363
+ if (typeof triggersPath !== "string" || triggersPath === "") return { ok: true };
364
+ const matched = job?.trigger?.matched;
365
+ const state = readDisarmState({
366
+ triggersPath,
367
+ index: matched?.index,
368
+ number: matched?.number ?? job?.target?.number,
369
+ flow: job?.flow,
370
+ command: job?.command,
371
+ fs,
372
+ });
373
+ if (state.state !== "disarmed") return { ok: true };
374
+ if (state.jobId !== null && state.jobId === queueJobId) return { ok: true }; // our own earlier attempt
375
+ return { refused: true, at: state.at, jobId: state.jobId };
376
+ };
377
+ }
378
+
379
+ export function readDisarmState({ triggersPath, index, number, flow, command, fs = nodeFs }) {
380
+ let raw;
381
+ try {
382
+ raw = JSON.parse(fs.readFileSync(triggersPath, "utf8"));
383
+ } catch (err) {
384
+ return { state: "unknown", reason: err?.code ?? "parse-error" };
385
+ }
386
+ const entry = Array.isArray(raw?.triggers) ? raw.triggers[index] : undefined;
387
+ if (!entry || typeof entry !== "object" || entry.on?.once !== true || entry.on?.number !== number) {
388
+ return { state: "unknown", reason: "entry-changed" };
389
+ }
390
+ // The disarm writer's identity fields, folded to "unknown" rather than refused: a re-armed
391
+ // DIFFERENT one-shot at this index is not spent, so the fail-open answer -- run -- is the true one.
392
+ if (flow !== undefined && (entry.run?.flow !== flow || entry.run?.command !== undefined)) {
393
+ return { state: "unknown", reason: "entry-changed" };
394
+ }
395
+ if (command !== undefined && (entry.run?.command !== command || entry.run?.flow !== undefined)) {
396
+ return { state: "unknown", reason: "entry-changed" };
397
+ }
398
+ if (entry.on.disarmed !== undefined) {
399
+ const d = entry.on.disarmed;
400
+ return { state: "disarmed", at: typeof d?.at === "string" ? d.at : null, jobId: typeof d?.jobId === "string" ? d.jobId : null };
401
+ }
402
+ return { state: "armed" };
403
+ }
package/src/triggers.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Shared trigger-file schema + validator (issue #20). One `triggers.json` of `{ on, run }` entries is
3
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
4
+ * (local jobs), the receiver owns the webhook types (`label|comment|pull_request|issue` -> a forge job). Each
5
5
  * service validates the WHOLE file, then selects the `on.type` it owns, so a malformed file fails both
6
6
  * identically and the two cannot drift.
7
7
  *
@@ -24,10 +24,25 @@
24
24
  */
25
25
 
26
26
  import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
27
+ // SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
28
+ // materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
29
+ // traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
30
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
27
31
  import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
28
32
  import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
29
33
 
30
- const ON_TYPES = new Set(["cron", "label", "comment", "pull_request"]);
34
+ const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
35
+
36
+ /**
37
+ * The `on.type` a DISARMED one-shot normalizes to (issue #231). Producible only by this validator:
38
+ * `ON_TYPES` excludes it, so an authored `on.type: "disarmed"` refuses like any unknown type. The
39
+ * sentinel keeps the entry's raw array position (deleting it would shift `triggerIndex` attribution
40
+ * for every later entry) while making it unmatchable BY CONSTRUCTION -- it carries no selectors, no
41
+ * actions, no run.kind and no flow, so no receiver group, no schedule and no flow allowlist can ever
42
+ * pick it up. A marker flag on the original type was rejected: one consumer forgetting to check it
43
+ * is a spent one-shot firing again, and this shape makes that bug unwritable.
44
+ */
45
+ export const DISARMED_TYPE = "disarmed";
31
46
 
32
47
  // `RUN_KINDS` and `FORGE_KINDS` come from the forge table (forges.mjs) rather than being written out
33
48
  // here. They differ by exactly `local`, and that difference IS the on x run matrix below: a webhook
@@ -41,8 +56,17 @@ export { FORGE_KINDS };
41
56
  * what their forge's documentation says and can grep for it there.
42
57
  *
43
58
  * GitLab has no `labeled`: adding a label to a merge request arrives as `update` carrying a
44
- * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`. `merge` and
45
- * `close` are omitted on purpose: a job started by a merge or a close has nothing left to act on.
59
+ * `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`.
60
+ *
61
+ * The close words (issue #231) carry a distinction the old "nothing left to act on" wording folded
62
+ * flat. A job ABOUT the closed thing still has nothing left to act on, and `run.replicas`' neighbour
63
+ * refusal still stands; what a close CAN do is release separately-armed work -- an operator wrote
64
+ * "when this closes, run that" into this file, and the close is the starting gun, not the subject.
65
+ * So `closed`/`close` are close-ONLY action lists (mixing them with other actions is refused below:
66
+ * the two families gate on different actors), while `merge` stays omitted everywhere -- GitHub and
67
+ * Forgejo emit `closed` for a merged PR so close rules cover merges there, and GitLab's `merge` is
68
+ * its own action no rule takes (an explicit close is what fires a GitLab close rule; the spec names
69
+ * the gap).
46
70
  *
47
71
  * `review_submitted` (issue #66) is github's fifth and the one compound word here. It names the
48
72
  * `pull_request_review` event's `submitted` action, so both halves are greppable in GitHub's own docs, the
@@ -53,23 +77,61 @@ export { FORGE_KINDS };
53
77
  * `author_association`, never the PR author's -- see filter.mjs and CONST-TRIGGER-AUTHOR-GATE.
54
78
  */
55
79
  const PR_ACTIONS = {
56
- github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
80
+ github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted", "closed"]),
57
81
  // GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
58
82
  // `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
59
- // what `on.reviewState` below exists to narrow.
60
- gitlab: new Set(["open", "update", "reopen", "approved"]),
83
+ // what `on.reviewState` below exists to narrow. `close` is GitLab's own spelling of the close action.
84
+ gitlab: new Set(["open", "update", "reopen", "approved", "close"]),
61
85
  // Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
62
86
  // one-letter difference that an operator would otherwise discover as a trigger that loads clean and
63
87
  // never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
64
88
  // start a paid run, and it has no GitHub counterpart to inherit that rule from.
65
- forgejo: new Set(["label_updated", "opened", "synchronized", "reopened"]),
89
+ forgejo: new Set(["label_updated", "opened", "synchronized", "reopened", "closed"]),
66
90
  // Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
67
- // is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
68
- // left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
69
- // requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
91
+ // is omitted for the reason the close words' own comment above records, and there is no close word here at
92
+ // all: an abandon arrives as `git.pullrequest.updated` with nothing in the projected subset to tell it from
93
+ // any other update, so an azure close rule could only ever fire on the wrong events or never. Widening
94
+ // INT-AZURE-PAYLOAD-SUBSET (a PR status field) is the gap to close before this set can grow. There is no
95
+ // label action either -- Azure attaches tags to WORK ITEMS, never to pull requests -- which is why azure's
96
+ // `prLabelAction` is null and a predicated PR rule is refused below.
70
97
  azure: new Set(["created", "updated"]),
71
98
  };
72
99
 
100
+ /**
101
+ * The one action per forge that closes a pull request, in that forge's own words (issue #231). Spelled
102
+ * once because three places turn on it: the close-only refusal in `normalizePullRequest` (a close rule
103
+ * gates on the CLOSER's write access, every other PR rule gates on the author's association or a
104
+ * collaborator's label, and one rule cannot gate on two different actors), the `capable` switch that
105
+ * admits `on.number`/`on.once` there, and the queue's semantic-key discriminant. Azure is absent for
106
+ * the subset reason the table above records, so `PR_ACTIONS.azure` simply never grows the word and the
107
+ * existing vocabulary refusal names azure on its own.
108
+ *
109
+ * EXPORTED for the two consumers that must never re-derive it: the receiver's grouping (a close-only
110
+ * rule routes through the close gate, every other PR rule through the author gate, and the split must
111
+ * be THIS table's) and the queue's semantic-key discriminant (a matched close action word is what
112
+ * marks a close job).
113
+ */
114
+ export const PR_CLOSE_ACTIONS = { github: "closed", gitlab: "close", forgejo: "closed" };
115
+
116
+ /**
117
+ * The `issue` action vocabulary, per forge, in each forge's own words (issue #231). One word each so
118
+ * far: the type exists for "when issue #40 closes, run deploy", and every other issue event already
119
+ * has a home (`label` for label predicates, `comment` for phrases). GitLab's word is `close` (its
120
+ * `object_attributes.action`), GitHub's and Forgejo's is `closed`.
121
+ *
122
+ * Azure is REFUSED rather than absent-and-unhandled, with its own message: a work item's close is a
123
+ * `System.State` transition whose terminal names vary by process template (Agile "Closed", Scrum
124
+ * "Done", plus "Resolved"), and the projected subset carries only `System.Tags` -- so matching a
125
+ * close needs both an INT-AZURE-PAYLOAD-SUBSET widening and a state vocabulary this version does not
126
+ * guess at. Not yet covered, not impossible -- validateResumeFlag's distinction, kept for the same
127
+ * reason.
128
+ */
129
+ const ISSUE_ACTIONS = {
130
+ github: new Set(["closed"]),
131
+ gitlab: new Set(["close"]),
132
+ forgejo: new Set(["closed"]),
133
+ };
134
+
73
135
  /**
74
136
  * The verdicts a submitted GitHub review can carry, in the webhook's own (lower-case) spelling, and the
75
137
  * vocabulary of the optional `on.reviewState` narrowing (issue #66).
@@ -175,8 +237,12 @@ function normalizeTrigger(entry, index, path, state) {
175
237
  if (run === null || typeof run !== "object") {
176
238
  throw configError(`${at}: "run" must be an object: ${path}`);
177
239
  }
240
+ // Joined from the set for the joined-vocabulary rule stated over the run.kind check below -- a type
241
+ // added to the vocabulary can never be
242
+ // refused by a message that does not mention it. This is also what keeps `DISARMED_TYPE` refused
243
+ // when authored: it is deliberately not in ON_TYPES, so it reads here as any other unknown type.
178
244
  if (!ON_TYPES.has(on.type)) {
179
- throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
245
+ throw configError(`${at}: on.type must be one of ${[...ON_TYPES].join("|")} (got ${JSON.stringify(on.type)}): ${path}`);
180
246
  }
181
247
  // The legal-values half of every message below is JOINED from the table rather than typed out, so a
182
248
  // forge added to the table can never be refused by a message that does not mention it -- which reads
@@ -195,9 +261,18 @@ function normalizeTrigger(entry, index, path, state) {
195
261
  if (!isForgeKind(run.kind)) {
196
262
  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}`);
197
263
  }
198
- if (on.type === "label") return normalizeLabel(on, run, index, path);
199
- if (on.type === "comment") return normalizeComment(on, run, index, path, state);
200
- return normalizePullRequest(on, run, index, path);
264
+ let normalized;
265
+ if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
266
+ else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
267
+ else if (on.type === "issue") normalized = normalizeIssue(on, run, index, path);
268
+ else normalized = normalizePullRequest(on, run, index, path);
269
+
270
+ // A disarmed one-shot has validated IN FULL above (a disarmed entry with a malformed run still
271
+ // refuses the file -- writeTriggers' fail-closed contract needs the whole file valid, and the
272
+ // worker's disarm only ever ADDS one key to an entry that already passed). Only then does it
273
+ // normalize to the sentinel, so its raw index survives and nothing downstream can match it.
274
+ if (on.disarmed !== undefined) return { on: { type: DISARMED_TYPE }, run: {} };
275
+ return normalized;
201
276
  }
202
277
 
203
278
  function normalizeCron(on, run, index, path, state) {
@@ -264,6 +339,10 @@ function normalizeCron(on, run, index, path, state) {
264
339
  // Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
265
340
  // returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
266
341
  validateReplicas(run, `cron trigger "${id}"`, path);
342
+ // Same posture for the close-trigger fields (issue #231): none has a legal value on cron.
343
+ validateNumber(on, `cron trigger "${id}"`, path, { onType: "cron" });
344
+ validateOnce(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
345
+ validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
267
346
  const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
268
347
  const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
269
348
 
@@ -600,6 +679,122 @@ function validatePredicate(on, index, path, requirePositive) {
600
679
  return { any: on.any, all: on.all, none: on.none };
601
680
  }
602
681
 
682
+ /**
683
+ * `on.number` -- narrow a close-capable trigger to ONE item, by the number the forge itself assigns
684
+ * (issue #231). Legal with or without `on.once`: a standing "every close of #40" rule is coherent
685
+ * narrowing, exactly as `on.reviewState` narrows without changing what the rule is. Called from ALL
686
+ * normalizers, `validateReplicas`' posture -- where it cannot apply it refuses, never ignores.
687
+ * `capable` is passed only by the close-capable paths (the `issue` normalizer, and a `pull_request`
688
+ * rule whose only action is the forge's close word).
689
+ */
690
+ function validateNumber(on, at, path, { capable = false, onType } = {}) {
691
+ const number = on.number;
692
+ if (number === undefined) return undefined;
693
+ if (!capable) {
694
+ if (onType === "cron") {
695
+ throw configError(`${at}: on.number is not available on a cron trigger -- a schedule fires on time, not on an item, so there is no delivery for a number to narrow: ${path}`);
696
+ }
697
+ throw configError(`${at}: on.number is not yet covered for ${onType} triggers here -- only the close routes read it, so on a rule they never serve it would sit in the file looking configured; narrowing labels, comments or non-close pull_request rules is a gap to close, not a limit: ${path}`);
698
+ }
699
+ if (!Number.isInteger(number) || number < 1) {
700
+ throw configError(`${at}: on.number must be an integer >= 1 when present -- the item number the forge itself assigns (on GitLab, the iid) (got ${JSON.stringify(number)}): ${path}`);
701
+ }
702
+ return number;
703
+ }
704
+
705
+ /**
706
+ * `on.once` -- a one-shot: the trigger fires, produces a run record, and the worker disarms it by
707
+ * adding `on.disarmed` to this entry (issue #231, DES-ONE-SHOT-DISARM-IN-THE-FILE). Strictly boolean
708
+ * and fail-loud, the house rule; `false` is legal and carried, validateResumeFlag's argument -- an
709
+ * operator who wrote down today's default must not be refused for stating present behaviour.
710
+ *
711
+ * `once: true` REQUIRES `on.number`, and the requirement is a race analysis, not taste: a numberless
712
+ * one-shot matched by two different items' closes inside one dedup window enqueues both before either
713
+ * disarm lands, and which item "spent" the trigger is a coin flip. With a number, concurrent
714
+ * duplicates are duplicates of the SAME item, which is what the delivery GUID and the semantic window
715
+ * actually bound -- and the number is the identity the disarm re-checks before it writes, so a file
716
+ * edited between enqueue and disarm refuses loudly instead of disarming a stranger.
717
+ *
718
+ * `once: true` is refused beside `run.replicas` (`false`, the written-down default, is not): "exactly
719
+ * one run" and "N sandboxes race" contradict on their face,
720
+ * and with N run records the disarm no longer says which one spent the trigger.
721
+ */
722
+ function validateOnce(on, run, at, path, { capable = false, onType } = {}) {
723
+ const once = on.once;
724
+ if (once === undefined) return undefined;
725
+ if (!capable) {
726
+ if (onType === "cron") {
727
+ throw configError(`${at}: on.once is not available on a cron trigger -- a one-shot that can re-arm is a schedule, and a cron job carries no matched delivery for the disarm to name; delete the entry when the work is done: ${path}`);
728
+ }
729
+ // The close-word hint is JOINED from the table (the same rule the vocabulary messages follow), so a
730
+ // forge gaining a close word later can never be pointed away from it by a stale sentence here.
731
+ const closeWords = Object.entries(PR_CLOSE_ACTIONS).map(([k, w]) => `${k} has ${JSON.stringify(w)}`).join(", ");
732
+ throw configError(`${at}: on.once is not yet covered here -- the disarm writes back to the one entry a delivery matched, and only a close delivery names the single item that spends it; use on.type "issue", or a pull_request rule whose only action is the close word (${closeWords}; azure has no close trigger yet): ${path}`);
733
+ }
734
+ if (typeof once !== "boolean") {
735
+ throw configError(`${at}: on.once must be true or false when present -- a truthy string arming a one-shot is exactly the drift this validator exists to refuse (got ${JSON.stringify(once)}): ${path}`);
736
+ }
737
+ if (once === true && on.number === undefined) {
738
+ throw configError(`${at}: on.once requires on.number -- a one-shot without a named item is spent by whichever close arrives first, and two different items closing inside one dedup window would race for it; the number is also the identity the disarm re-checks before it writes: ${path}`);
739
+ }
740
+ if (once === true && run.replicas !== undefined) {
741
+ throw configError(`${at}: on.once and run.replicas cannot be combined -- "exactly one run" and "N sandboxes race" contradict, and with N run records the disarm no longer says which one spent the trigger: ${path}`);
742
+ }
743
+ return once;
744
+ }
745
+
746
+ /**
747
+ * `on.disarmed` -- the mark the worker writes when a one-shot fires: `{ at, jobId? }`, provenance an
748
+ * operator can read a year later when the run record is long reaped (issue #231). Hand-writable too
749
+ * (jobId optional), which is how an operator disarms deliberately; deleting the key is how they
750
+ * re-arm. The entry it sits on normalizes to the DISARMED_TYPE sentinel -- see normalizeTrigger --
751
+ * but only AFTER this shape check and the full entry validation pass, so a corrupted disarm mark is
752
+ * a load refusal, never a silently-still-armed rule.
753
+ */
754
+ function validateDisarmed(on, at, path, { capable = false, onType } = {}) {
755
+ const disarmed = on.disarmed;
756
+ if (disarmed === undefined) return undefined;
757
+ if (!capable) {
758
+ throw configError(`${at}: on.disarmed marks a spent one-shot, and on.once is not ${onType === "cron" ? "available on a cron trigger" : "yet covered here"}, so there is nothing this entry could have spent: ${path}`);
759
+ }
760
+ if (disarmed === null || typeof disarmed !== "object" || Array.isArray(disarmed)) {
761
+ throw configError(`${at}: on.disarmed must be an object with a non-empty string "at" (and optionally a non-empty string "jobId") -- the worker writes it when the one-shot fires, and a hand-written one disarms the entry deliberately (got ${JSON.stringify(disarmed)}): ${path}`);
762
+ }
763
+ for (const key of Object.keys(disarmed)) {
764
+ if (key !== "at" && key !== "jobId") {
765
+ throw configError(`${at}: on.disarmed has an unsupported key ${JSON.stringify(key)} (expected at|jobId): ${path}`);
766
+ }
767
+ }
768
+ if (!isNonEmptyString(disarmed.at)) {
769
+ throw configError(`${at}: on.disarmed.at must be a non-empty string (the time the one-shot fired): ${path}`);
770
+ }
771
+ if (disarmed.jobId !== undefined && !isNonEmptyString(disarmed.jobId)) {
772
+ throw configError(`${at}: on.disarmed.jobId must be a non-empty string when present (the run record it joins to): ${path}`);
773
+ }
774
+ if (on.once !== true) {
775
+ throw configError(`${at}: on.disarmed is only meaningful beside on.once: true -- an entry that was never a one-shot has nothing to spend: ${path}`);
776
+ }
777
+ return disarmed;
778
+ }
779
+
780
+ /**
781
+ * `run.flow` charset for the WEBHOOK kinds (issue #231). materialize.mjs refuses a name outside
782
+ * SKILL_NAME_RE at job start, AFTER the budget slot is reserved -- so until now a charset-invalid
783
+ * forge flow loaded clean and could only ever fail in-container (graph-model already renders it as
784
+ * the `charset-invalid` defect). Refusing at load turns that paid failure into a free one, and it is
785
+ * also what keeps `:` out of the flow slot of the queue's semantic dedup key, where the command jobs'
786
+ * `cmd:` prefix lives and the close jobs' discriminant sits beside it once close routing lands.
787
+ * Deliberately NOT called on cron:
788
+ * a local flow resolves inside the operator's own folder by pi itself, and the semantic key does not
789
+ * apply to repeat jobs -- narrowing there would refuse deployments this hazard cannot reach.
790
+ */
791
+ function validateFlowName(run, at, path) {
792
+ if (run.flow === undefined) return;
793
+ if (!SKILL_NAME_RE.test(run.flow)) {
794
+ throw configError(`${at}: run.flow ${JSON.stringify(run.flow)} fails the skill-name charset (lowercase letters, digits, dash and underscore, 1-64 chars, starting and ending alphanumeric) -- a name outside it can never materialise, so this trigger could only ever fail after the budget slot was reserved: ${path}`);
795
+ }
796
+ }
797
+
603
798
 
604
799
  /**
605
800
  * `run.repository` -- WHICH repository a job clones, for a forge whose trigger subject does not name one.
@@ -792,11 +987,15 @@ function validateSecretsProfile(run, at, path) {
792
987
  function normalizeLabel(on, run, index, path) {
793
988
  const at = `trigger at index ${index}`;
794
989
  const predicate = validatePredicate(on, index, path, true);
990
+ validateNumber(on, at, path, { onType: "label" });
991
+ validateOnce(on, run, at, path, { onType: "label" });
992
+ validateDisarmed(on, at, path, { onType: "label" });
795
993
  // First among the run checks, before the flow-required check -- validateCommand says why.
796
994
  const command = validateCommand(run, at, path, { onType: "label" });
797
995
  if (command === undefined && !isNonEmptyString(run.flow)) {
798
996
  throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
799
997
  }
998
+ validateFlowName(run, at, path);
800
999
  const packages = validatePackagesFlag(run, at, path);
801
1000
  const image = validateImageRef(run, at, path);
802
1001
  const skillsDir = validateSkillsDir(run, at, path);
@@ -817,6 +1016,9 @@ function normalizeComment(on, run, index, path, state) {
817
1016
  if (!isNonEmptyString(on.phrase)) {
818
1017
  throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
819
1018
  }
1019
+ validateNumber(on, at, path, { onType: "comment" });
1020
+ validateOnce(on, run, at, path, { onType: "comment" });
1021
+ validateDisarmed(on, at, path, { onType: "comment" });
820
1022
  // First among the run checks, before the flow-required check -- validateCommand says why. A command
821
1023
  // trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
822
1024
  // token inert is the receiver filter's job, not a shape this validator can see.
@@ -824,6 +1026,7 @@ function normalizeComment(on, run, index, path, state) {
824
1026
  if (command === undefined && !isNonEmptyString(run.flow)) {
825
1027
  throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
826
1028
  }
1029
+ validateFlowName(run, at, path);
827
1030
  // At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
828
1031
  // per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
829
1032
  // and a deployment serving GitHub and GitLab is entitled to the same `@pi` phrase on each.
@@ -846,6 +1049,78 @@ function normalizeComment(on, run, index, path, state) {
846
1049
  };
847
1050
  }
848
1051
 
1052
+ /**
1053
+ * `on.type: "issue"` (issue #231): fire when an ISSUE's lifecycle action happens -- one word so far,
1054
+ * the close. The type exists because every other issue event already has a home (`label` for label
1055
+ * predicates, `comment` for phrases) and neither of those shapes fits a close: there is no label diff
1056
+ * to match and no phrase to read, only an action, an item number, and the actor who performed it.
1057
+ * A PULL REQUEST's close is deliberately NOT this type -- it rides `on.type: "pull_request"` as an
1058
+ * action, the #66 rule (one forge's PR lifecycle must not be a type while another's is an action),
1059
+ * and because GitLab and Azure number issues and merge/pull requests from SEPARATE sequences, a
1060
+ * both-kinds type narrowed by `on.number` would be ambiguous exactly where a one-shot spending
1061
+ * itself on the wrong #5 hurts most. Under the split, the type IS the discriminator.
1062
+ */
1063
+ function normalizeIssue(on, run, index, path) {
1064
+ const at = `trigger at index ${index}`;
1065
+
1066
+ // Kind first: on azure the whole TYPE is unsupported, and hearing that beats hearing that one
1067
+ // action word is. See ISSUE_ACTIONS for why, and validateResumeFlag for the "not yet covered"
1068
+ // vocabulary this reuses.
1069
+ if (run.kind === "azure") {
1070
+ throw configError(`${at}: an issue trigger is not yet covered for azure -- a work item's close is a System.State transition whose terminal names vary by process template (Agile "Closed", Scrum "Done"), and the projected subset carries only System.Tags, so nothing in the delivery says the item closed; widening the payload subset is the gap to close, not a limit: ${path}`);
1071
+ }
1072
+
1073
+ const actions = on.action;
1074
+ if (!Array.isArray(actions) || actions.length === 0) {
1075
+ throw configError(`${at}: issue on.action must be a non-empty array: ${path}`);
1076
+ }
1077
+ // Validated against THIS entry's forge, in that forge's own words -- normalizePullRequest's rule.
1078
+ const allowed = ISSUE_ACTIONS[run.kind];
1079
+ const expected = [...allowed].join("|");
1080
+ for (const a of actions) {
1081
+ if (!allowed.has(a)) {
1082
+ throw configError(`${at}: issue on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
1083
+ }
1084
+ }
1085
+
1086
+ // The close route matches action and number alone, so a predicate here would be accepted-and-
1087
+ // ignored -- the exact thing validateRepository's posture forbids. Refused, not dropped.
1088
+ if (on.any !== undefined || on.all !== undefined || on.none !== undefined) {
1089
+ throw configError(`${at}: an issue trigger cannot carry a label predicate -- it fires on a lifecycle action, not a label diff, so any/all/none could never match; a label-predicated rule is on.type "label": ${path}`);
1090
+ }
1091
+
1092
+ const number = validateNumber(on, at, path, { capable: true, onType: "issue" });
1093
+ const once = validateOnce(on, run, at, path, { capable: true, onType: "issue" });
1094
+ validateDisarmed(on, at, path, { capable: true, onType: "issue" });
1095
+
1096
+ // First among the run checks, before the flow-required check -- validateCommand says why.
1097
+ const command = validateCommand(run, at, path, { onType: "issue" });
1098
+ if (command === undefined && !isNonEmptyString(run.flow)) {
1099
+ throw configError(`${at}: issue trigger run.flow must be a non-empty string (or use run.command): ${path}`);
1100
+ }
1101
+ validateFlowName(run, at, path);
1102
+ const packages = validatePackagesFlag(run, at, path);
1103
+ const image = validateImageRef(run, at, path);
1104
+ const skillsDir = validateSkillsDir(run, at, path);
1105
+ const instructions = validateInstructions(run, at, path);
1106
+ const resume = validateResumeFlag(run, at, path);
1107
+ validateRepository(run, "issue", at, path);
1108
+ const replicas = validateReplicas(run, at, path);
1109
+ const secrets = validateSecrets(run, at, path);
1110
+ const secretsProfile = validateSecretsProfile(run, at, path);
1111
+ return {
1112
+ on: {
1113
+ type: "issue",
1114
+ action: [...actions],
1115
+ // Absent rather than present-and-undefined, reviewState's rule below: an unnarrowed rule's
1116
+ // normalized shape must not grow keys.
1117
+ ...(number !== undefined && { number }),
1118
+ ...(once !== undefined && { once }),
1119
+ },
1120
+ run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
1121
+ };
1122
+ }
1123
+
849
1124
  function normalizePullRequest(on, run, index, path) {
850
1125
  const at = `trigger at index ${index}`;
851
1126
 
@@ -863,6 +1138,16 @@ function normalizePullRequest(on, run, index, path) {
863
1138
  }
864
1139
  }
865
1140
 
1141
+ // The close-only split (issue #231). A close rule gates on the CLOSER's write access; every other
1142
+ // PR action gates on the author's association or a collaborator-applied label. One rule cannot
1143
+ // gate on two different actors, so a list mixing the close word with anything else is refused --
1144
+ // which is also what lets the receiver group close rules separately without re-deriving this.
1145
+ const closeWord = PR_CLOSE_ACTIONS[run.kind];
1146
+ const isClose = closeWord !== undefined && actions.includes(closeWord);
1147
+ if (isClose && actions.some((a) => a !== closeWord)) {
1148
+ throw configError(`${at}: pull_request on.action cannot mix ${JSON.stringify(closeWord)} with other actions -- a close rule is gated on the closer's write access and every other action on the author's, and one rule cannot gate on two different actors; split it into two entries: ${path}`);
1149
+ }
1150
+
866
1151
  const reviewState = validateReviewState(on, actions, run, at, path);
867
1152
 
868
1153
  // A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
@@ -882,12 +1167,21 @@ function normalizePullRequest(on, run, index, path) {
882
1167
  if (run.kind === "azure" && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
883
1168
  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}`);
884
1169
  }
1170
+ // A close-only rule reads no label diff either -- its route matches action and number alone -- so
1171
+ // a predicate on it is normalizeIssue's refusal arriving on the PR side.
1172
+ if (isClose && (on.any !== undefined || on.all !== undefined || on.none !== undefined)) {
1173
+ throw configError(`${at}: a close pull_request rule cannot carry a label predicate -- the close route matches the action and on.number alone, so any/all/none would sit in the file looking configured and never match anything: ${path}`);
1174
+ }
885
1175
  const predicate = validatePredicate(on, index, path, requirePositive);
1176
+ const number = validateNumber(on, at, path, { capable: isClose, onType: "pull_request" });
1177
+ const once = validateOnce(on, run, at, path, { capable: isClose, onType: "pull_request" });
1178
+ validateDisarmed(on, at, path, { capable: isClose, onType: "pull_request" });
886
1179
  // First among the run checks, before the flow-required check -- validateCommand says why.
887
1180
  const command = validateCommand(run, at, path, { onType: "pull_request" });
888
1181
  if (command === undefined && !isNonEmptyString(run.flow)) {
889
1182
  throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
890
1183
  }
1184
+ validateFlowName(run, at, path);
891
1185
  const packages = validatePackagesFlag(run, at, path);
892
1186
  const image = validateImageRef(run, at, path);
893
1187
  const skillsDir = validateSkillsDir(run, at, path);
@@ -907,6 +1201,9 @@ function normalizePullRequest(on, run, index, path) {
907
1201
  any: predicate.any,
908
1202
  all: predicate.all,
909
1203
  none: predicate.none,
1204
+ // Same rule for the close-narrowing pair (issue #231): only a close-only rule can carry them.
1205
+ ...(number !== undefined && { number }),
1206
+ ...(once !== undefined && { once }),
910
1207
  },
911
1208
  run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
912
1209
  };