@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.
- package/deploy/docker-compose.yml +5 -1
- package/package.json +2 -1
- package/src/doctor.mjs +38 -3
- package/src/get-token.mjs +6 -1
- package/src/index.mjs +5 -0
- package/src/processor.mjs +26 -0
- package/src/queue.mjs +21 -1
- package/src/run-history.mjs +50 -31
- package/src/start.mjs +25 -2
- package/src/triggers-file.mjs +403 -0
- package/src/triggers.mjs +312 -15
|
@@ -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
|
+
"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
|
|
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}:${
|
|
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
|
package/src/run-history.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
45
|
-
*
|
|
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
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
|
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
|
-
|
|
199
|
-
if (on.type === "
|
|
200
|
-
|
|
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
|
};
|