@edgehero/pi-dispatch 0.1.1 → 0.2.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/.env.example +2 -1
- package/deploy/com.pi-dispatch.worker.plist +16 -6
- package/deploy/docker-compose.yml +66 -0
- package/deploy/worker-env-wrapper.cmd +23 -14
- package/deploy/worker-env-wrapper.sh +22 -15
- package/package.json +1 -1
- package/src/azure-prompt.mjs +14 -8
- package/src/cli.mjs +2 -2
- package/src/copy-tree.mjs +215 -0
- package/src/doctor.mjs +283 -8
- package/src/forgejo-prompt.mjs +14 -8
- package/src/github-app-setup.mjs +7 -2
- package/src/github-prompt.mjs +97 -17
- package/src/gitlab-prompt.mjs +14 -8
- package/src/host-pi.mjs +478 -0
- package/src/import-pi.mjs +299 -139
- package/src/materialize.mjs +262 -40
- package/src/outbox.mjs +5 -0
- package/src/packages.mjs +95 -2
- package/src/prepare-github.mjs +23 -4
- package/src/prepare-local.mjs +6 -1
- package/src/prepare.mjs +73 -14
- package/src/processor.mjs +89 -10
- package/src/queue.mjs +16 -2
- package/src/run-container.mjs +7 -2
- package/src/schedules.mjs +16 -1
- package/src/service.mjs +135 -40
- package/src/session-store.mjs +19 -4
- package/src/start.mjs +54 -9
- package/src/triggers.mjs +211 -12
package/src/triggers.mjs
CHANGED
|
@@ -40,12 +40,22 @@ export { FORGE_KINDS };
|
|
|
40
40
|
* what their forge's documentation says and can grep for it there.
|
|
41
41
|
*
|
|
42
42
|
* GitLab has no `labeled`: adding a label to a merge request arrives as `update` carrying a
|
|
43
|
-
* `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`. `
|
|
44
|
-
* GitHub counterpart at all and is a genuinely useful gate (a member approved the MR). `merge` and
|
|
43
|
+
* `changes.labels` diff, and `open`/`reopen` are its spellings of `opened`/`reopened`. `merge` and
|
|
45
44
|
* `close` are omitted on purpose: a job started by a merge or a close has nothing left to act on.
|
|
45
|
+
*
|
|
46
|
+
* `review_submitted` (issue #66) is github's fifth and the one compound word here. It names the
|
|
47
|
+
* `pull_request_review` event's `submitted` action, so both halves are greppable in GitHub's own docs, the
|
|
48
|
+
* same reason Forgejo's `label_updated` is spelled Forgejo's way. It is also the first case where ONE
|
|
49
|
+
* `on.type` covers TWO GitHub event names: a review is an event about a pull request, and GitLab's
|
|
50
|
+
* analogue `approved` already rides `on.type: "pull_request"`, so making GitHub's a fifth `on.type` would
|
|
51
|
+
* have made one forge's review a type and the other's an action. The gate on it is the REVIEWER's
|
|
52
|
+
* `author_association`, never the PR author's -- see filter.mjs and CONST-TRIGGER-AUTHOR-GATE.
|
|
46
53
|
*/
|
|
47
54
|
const PR_ACTIONS = {
|
|
48
|
-
github: new Set(["labeled", "opened", "synchronize", "reopened"]),
|
|
55
|
+
github: new Set(["labeled", "opened", "synchronize", "reopened", "review_submitted"]),
|
|
56
|
+
// GitLab's `approved` is its review gate (a member approved the MR). It is NOT github's
|
|
57
|
+
// `review_submitted` renamed: `approved` is one verdict, `review_submitted` is every verdict, which is
|
|
58
|
+
// what `on.reviewState` below exists to narrow.
|
|
49
59
|
gitlab: new Set(["open", "update", "reopen", "approved"]),
|
|
50
60
|
// Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
|
|
51
61
|
// one-letter difference that an operator would otherwise discover as a trigger that loads clean and
|
|
@@ -59,6 +69,24 @@ const PR_ACTIONS = {
|
|
|
59
69
|
azure: new Set(["created", "updated"]),
|
|
60
70
|
};
|
|
61
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The verdicts a submitted GitHub review can carry, in the webhook's own (lower-case) spelling, and the
|
|
74
|
+
* vocabulary of the optional `on.reviewState` narrowing (issue #66).
|
|
75
|
+
*
|
|
76
|
+
* The narrowing exists because `review_submitted` is a WIDER paid surface than any other GitHub trigger:
|
|
77
|
+
* an approve, a request-changes and a drive-by "lgtm thanks" all submit a review, and unlike a comment
|
|
78
|
+
* trigger there is no phrase in the way and unlike a label trigger there is no label. `["changes_requested"]`
|
|
79
|
+
* is the arming most operators actually want. Omitted means all three, so the default is the issue's own
|
|
80
|
+
* shape and the narrowing only ever subtracts.
|
|
81
|
+
*
|
|
82
|
+
* `dismissed` is absent because it is an ACTION on the `pull_request_review` event, not a state a
|
|
83
|
+
* submitted review carries.
|
|
84
|
+
*/
|
|
85
|
+
const REVIEW_STATES = new Set(["approved", "changes_requested", "commented"]);
|
|
86
|
+
|
|
87
|
+
/** The one action `on.reviewState` can narrow. Spelled once, read by the validator and named in its error. */
|
|
88
|
+
const REVIEW_ACTION = "review_submitted";
|
|
89
|
+
|
|
62
90
|
// A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
|
|
63
91
|
// parse; the charset also excludes `:` and the dedicated check names the reason.
|
|
64
92
|
const ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
@@ -183,6 +211,12 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
183
211
|
|
|
184
212
|
const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
|
|
185
213
|
const image = validateImageRef(run, `cron trigger "${id}"`, path);
|
|
214
|
+
const skillsDir = validateSkillsDir(run, `cron trigger "${id}"`, path);
|
|
215
|
+
validateInstructions(run, `cron trigger "${id}"`, path, { cron: true });
|
|
216
|
+
// RETURNED, not discarded like validateReplicas below, because `resume` still has a legal value on a
|
|
217
|
+
// cron entry: only `true` is refused (the local path has nothing to resume with), so what survives is
|
|
218
|
+
// `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
|
|
219
|
+
// today's default must not get a `data` that disagrees with the file they reviewed.
|
|
186
220
|
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
187
221
|
// Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
|
|
188
222
|
// returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
|
|
@@ -195,7 +229,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
195
229
|
// freeze today's default into every stored repeatable.
|
|
196
230
|
return {
|
|
197
231
|
on: { type: "cron", id, pattern },
|
|
198
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume },
|
|
232
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(skillsDir !== undefined && { skillsDir }) },
|
|
199
233
|
};
|
|
200
234
|
}
|
|
201
235
|
|
|
@@ -230,9 +264,25 @@ function validatePackagesFlag(run, at, path) {
|
|
|
230
264
|
* to host disk and replayed into a later job on the same key. Disclosures default off. Absent and `false`
|
|
231
265
|
* both mean today's behaviour, with not one byte written to disk and no /session mount in the argv.
|
|
232
266
|
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
267
|
+
* REFUSED on a CRON trigger, and the refusal is the honest half of this validator rather than a limit of
|
|
268
|
+
* the feature. `resolveSession` is handed to the FORGE preparers only (prepare.mjs); the local branch
|
|
269
|
+
* returns before it is ever in scope, and prepare-local.mjs contains no session code at all -- so an armed
|
|
270
|
+
* cron trigger stages no transcript, mounts no /session, promotes nothing, and then exits 0 as though it
|
|
271
|
+
* had. `validateReplicas` states the argument in one line and it applies verbatim here: a field accepted
|
|
272
|
+
* where it does nothing is how an operator comes to trust one that does nothing.
|
|
273
|
+
*
|
|
274
|
+
* "NOT YET COVERED", not impossible -- validateReplicas' own distinction, kept because the two are
|
|
275
|
+
* different facts and an operator planning work needs the right one. The local key already exists and is
|
|
276
|
+
* the strongest key in this feature: session-key.mjs keys a cron job on its scheduler id, which is
|
|
277
|
+
* operator-authored, unique across the file, stable across fires, and chosen by nobody untrusted. Nothing
|
|
278
|
+
* reaches it. Wiring `resolveSession` into the local path is a feature, and this line is what stops the
|
|
279
|
+
* flag from pretending that feature landed in the meantime.
|
|
280
|
+
*
|
|
281
|
+
* Only `true` is refused, and the asymmetry with validateReplicas -- which refuses ANY value on cron -- is
|
|
282
|
+
* deliberate. `run.replicas: 1` is refused because a one-member replica set is a flag that does nothing,
|
|
283
|
+
* so the field has no legal no-op value; `run.resume: false` IS the documented default, so refusing it
|
|
284
|
+
* would refuse an operator for writing down the behaviour they already have, and would change a normalized
|
|
285
|
+
* shape that has to stay byte-identical.
|
|
236
286
|
*
|
|
237
287
|
* Strictly boolean and fail-loud, the house rule -- and here the damaging misreading is a truthy `"false"`
|
|
238
288
|
* string, which reads to an operator as an opt-out and would arm the disclosure instead. That is the exact
|
|
@@ -240,7 +290,9 @@ function validatePackagesFlag(run, at, path) {
|
|
|
240
290
|
*
|
|
241
291
|
* Type here, reality at job start, exactly as `run.image` splits it: this cannot know whether
|
|
242
292
|
* PI_SESSIONS_DIR is set, whether a key resolves, or whether a transcript exists. Those are the worker's
|
|
243
|
-
* to answer, and all but the first degrade to a cold start rather than refusing
|
|
293
|
+
* to answer, and all but the first degrade to a cold start rather than refusing -- the first is the one
|
|
294
|
+
* pre-spend policy refusal, `sessions-dir-unset` in processor.mjs (REQ-RESUMABLE-SESSION fails CLOSED
|
|
295
|
+
* there and only there).
|
|
244
296
|
*
|
|
245
297
|
* `at` is the caller's message prefix. Returns the flag, undefined when absent, so an unflagged trigger
|
|
246
298
|
* normalizes byte-identically to today's.
|
|
@@ -249,6 +301,14 @@ function validateResumeFlag(run, at, path) {
|
|
|
249
301
|
if (run.resume !== undefined && typeof run.resume !== "boolean") {
|
|
250
302
|
throw configError(`${at}: run.resume must be true or false when present: ${path}`);
|
|
251
303
|
}
|
|
304
|
+
// `run.kind === "local"` IS "this is a cron trigger": normalizeTrigger has already refused every other
|
|
305
|
+
// pairing of on.type and run.kind, so the matrix makes the two synonyms. The same test validateReplicas
|
|
306
|
+
// keys its first refusal on, for the same reason -- neither wants to be re-taught the matrix.
|
|
307
|
+
if (run.resume === true && run.kind === "local") {
|
|
308
|
+
throw configError(
|
|
309
|
+
`${at}: run.resume is not yet covered for cron triggers (forge triggers only in this version) -- resolveSession is handed to the forge preparers only, so a local job would stage no transcript, mount no /session and promote nothing, then exit 0 as though it had; the local session key exists in session-key.mjs and nothing reaches it, so this is a gap to close, not a limit: ${path}`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
252
312
|
return run.resume;
|
|
253
313
|
}
|
|
254
314
|
|
|
@@ -298,6 +358,98 @@ function validateImageRef(run, at, path) {
|
|
|
298
358
|
return image;
|
|
299
359
|
}
|
|
300
360
|
|
|
361
|
+
/**
|
|
362
|
+
* `run.skillsDir` (issue #60): a directory of operator-authored skills on the WORKER host, copied into
|
|
363
|
+
* this trigger's jobs and layered between the repo's own `.pi/skills` and the global overlay.
|
|
364
|
+
*
|
|
365
|
+
* Accepted on all four run kinds, for `run.image`'s reason restated: a skill set is a capability of the
|
|
366
|
+
* FLOW, and a label/comment/PR trigger runs the flows a cron trigger runs. The copy site in prepare.mjs
|
|
367
|
+
* is shared by every kind, so accepting it everywhere accepts it where it works.
|
|
368
|
+
*
|
|
369
|
+
* TWO checks are deliberately NOT here, and both would be bugs if they were.
|
|
370
|
+
*
|
|
371
|
+
* EXISTENCE is not checked, because BOTH services parse this file and the receiver may run on a
|
|
372
|
+
* different host entirely, where a worker-side path means nothing. That is `run.folder`'s split
|
|
373
|
+
* exactly: type here, reality where it can be known -- at worker boot for cron (schedules.mjs) and
|
|
374
|
+
* pre-spend per job for every kind (processor.mjs).
|
|
375
|
+
*
|
|
376
|
+
* ABSOLUTENESS is not checked either, and this one is subtler. `path.isAbsolute` is OS-DEPENDENT:
|
|
377
|
+
* `"C:\\skills"` is absolute on win32 and relative on posix. This worker is cross-platform (see
|
|
378
|
+
* materialize.mjs's safeJoin, written with path.relative for that reason), so enforcing it in the
|
|
379
|
+
* SHARED validator would let a Windows worker and a Linux receiver disagree about whether the same
|
|
380
|
+
* reviewed file is valid -- a file that loads on one service and refuses on the other is worse than a
|
|
381
|
+
* late refusal. The worker enforces it where the answer is knowable.
|
|
382
|
+
*
|
|
383
|
+
* No charset either: this is an absolute host path chosen by an operator who can already name any path
|
|
384
|
+
* in `run.folder`, so traversal is not a threat model here. Containment is enforced where it can be, on
|
|
385
|
+
* the DESTINATION side, by copy-tree.mjs's validated-segment rebuild and safeJoin.
|
|
386
|
+
*/
|
|
387
|
+
function validateSkillsDir(run, at, path) {
|
|
388
|
+
const dir = run.skillsDir;
|
|
389
|
+
if (dir === undefined) return undefined;
|
|
390
|
+
if (typeof dir !== "string" || dir.trim() === "") {
|
|
391
|
+
throw configError(`${at}: run.skillsDir must be a non-empty string when present: ${path}`);
|
|
392
|
+
}
|
|
393
|
+
// Whitespace is refused rather than trimmed, for validateImageRef's reason: the file is the reviewed
|
|
394
|
+
// artifact and must not disagree with what runs.
|
|
395
|
+
if (dir !== dir.trim()) {
|
|
396
|
+
throw configError(`${at}: run.skillsDir must not have leading or trailing whitespace (got ${JSON.stringify(dir)}): ${path}`);
|
|
397
|
+
}
|
|
398
|
+
return dir;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The ceiling on `run.instructions` (REQ-PER-TRIGGER-INSTRUCTION, issue #60).
|
|
403
|
+
*
|
|
404
|
+
* NOT a caching bound, and the entry says so rather than letting a reader assume it. The text is written
|
|
405
|
+
* once into /job/prompt.md and `session.prompt()` is called once, so the pattern
|
|
406
|
+
* CONST-PERSONA-IN-CACHED-PREFIX names -- injecting a persistent user message on every prompt -- is not
|
|
407
|
+
* what this is; and at the pin, pi-ai attaches cache_control to the LAST USER MESSAGE as well as the
|
|
408
|
+
* system prompt, so after turn one this sits in the cached prefix at roughly the persona's rate anyway.
|
|
409
|
+
*
|
|
410
|
+
* What the cap is actually for is two other things. A field with no bound invites a 200 KB style guide
|
|
411
|
+
* pasted in, which overflows context inside a PAID container on every delivery of that trigger, with no
|
|
412
|
+
* pre-spend signal -- a cap turns that into a free load-time refusal in both services. And it keeps the
|
|
413
|
+
* field in its lane: a standing instruction is a sentence or two, and anything longer belongs in the
|
|
414
|
+
* flow's own SKILL.md (versioned, reviewed) or in the overlay persona (deploy-time, system prompt). The
|
|
415
|
+
* refusal message names both destinations, so the cap teaches rather than merely blocks.
|
|
416
|
+
*/
|
|
417
|
+
const INSTRUCTIONS_MAX = 2000;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* `run.instructions` (issue #60): one line of operator standing text, rendered into the USER prompt's
|
|
421
|
+
* envelope above the fenced data region.
|
|
422
|
+
*
|
|
423
|
+
* REFUSED on cron, and it is a DIFFERENT refusal from run.replicas' "not yet covered": cron already has
|
|
424
|
+
* an operator-authored free-text field landing in the same region of the same file. A local job's prompt
|
|
425
|
+
* is `flow hint + pointer + run.task` with no envelope, no data heading and no fence (prepare.mjs), so
|
|
426
|
+
* there is no "standing" region distinct from the task for a second field to occupy. Two fields writing
|
|
427
|
+
* one region with an undefined combination order is worse than a field that does nothing, because both
|
|
428
|
+
* would appear to work.
|
|
429
|
+
*
|
|
430
|
+
* Whitespace is NOT refused here, unlike run.image, and the divergence is deliberate: that rule exists
|
|
431
|
+
* because whitespace changes what an image REFERENCE means, and it does not change what prose means. A
|
|
432
|
+
* trailing newline in a multi-line JSON string is a papercut, not a hazard. A whitespace-ONLY value is
|
|
433
|
+
* refused, because that is a field the operator believes they set.
|
|
434
|
+
*
|
|
435
|
+
* Refused rather than truncated at the cap, for validateImageRef's reason: the file is the reviewed
|
|
436
|
+
* artifact and must not disagree with what runs.
|
|
437
|
+
*/
|
|
438
|
+
function validateInstructions(run, at, path, { cron = false } = {}) {
|
|
439
|
+
const text = run.instructions;
|
|
440
|
+
if (text === undefined) return undefined;
|
|
441
|
+
if (cron) {
|
|
442
|
+
throw configError(`${at}: run.instructions is not accepted on a cron trigger -- a local job's prompt IS run.task, the same operator-authored text in the same place. Put the standing instruction at the top of run.task: ${path}`);
|
|
443
|
+
}
|
|
444
|
+
if (typeof text !== "string" || text.trim() === "") {
|
|
445
|
+
throw configError(`${at}: run.instructions must be a non-empty string when present: ${path}`);
|
|
446
|
+
}
|
|
447
|
+
if (text.length > INSTRUCTIONS_MAX) {
|
|
448
|
+
throw configError(`${at}: run.instructions is ${text.length} characters, over the ${INSTRUCTIONS_MAX} cap -- a standing instruction is a sentence or two. Anything longer belongs in the flow's own SKILL.md, or in the global overlay's APPEND_SYSTEM.md if it applies to every job: ${path}`);
|
|
449
|
+
}
|
|
450
|
+
return text;
|
|
451
|
+
}
|
|
452
|
+
|
|
301
453
|
/**
|
|
302
454
|
* Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
|
|
303
455
|
* BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
|
|
@@ -406,12 +558,14 @@ function normalizeLabel(on, run, index, path) {
|
|
|
406
558
|
}
|
|
407
559
|
const packages = validatePackagesFlag(run, at, path);
|
|
408
560
|
const image = validateImageRef(run, at, path);
|
|
561
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
562
|
+
const instructions = validateInstructions(run, at, path);
|
|
409
563
|
const resume = validateResumeFlag(run, at, path);
|
|
410
564
|
const repository = validateRepository(run, "label", at, path);
|
|
411
565
|
const replicas = validateReplicas(run, at, path);
|
|
412
566
|
return {
|
|
413
567
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
414
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(repository !== undefined && { repository }) },
|
|
568
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
415
569
|
};
|
|
416
570
|
}
|
|
417
571
|
|
|
@@ -432,12 +586,14 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
432
586
|
}
|
|
433
587
|
const packages = validatePackagesFlag(run, at, path);
|
|
434
588
|
const image = validateImageRef(run, at, path);
|
|
589
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
590
|
+
const instructions = validateInstructions(run, at, path);
|
|
435
591
|
const resume = validateResumeFlag(run, at, path);
|
|
436
592
|
const repository = validateRepository(run, "comment", at, path);
|
|
437
593
|
const replicas = validateReplicas(run, at, path);
|
|
438
594
|
return {
|
|
439
595
|
on: { type: "comment", phrase: on.phrase },
|
|
440
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(repository !== undefined && { repository }) },
|
|
596
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
441
597
|
};
|
|
442
598
|
}
|
|
443
599
|
|
|
@@ -458,6 +614,8 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
458
614
|
}
|
|
459
615
|
}
|
|
460
616
|
|
|
617
|
+
const reviewState = validateReviewState(on, actions, run, at, path);
|
|
618
|
+
|
|
461
619
|
// A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
|
|
462
620
|
// approval), so it MUST carry a positive selector -- exactly as a label trigger does. Auto actions
|
|
463
621
|
// (opened/synchronize/reopened) are gated by author_association in the filter, so a predicate is
|
|
@@ -481,11 +639,52 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
481
639
|
}
|
|
482
640
|
const packages = validatePackagesFlag(run, at, path);
|
|
483
641
|
const image = validateImageRef(run, at, path);
|
|
642
|
+
const skillsDir = validateSkillsDir(run, at, path);
|
|
643
|
+
const instructions = validateInstructions(run, at, path);
|
|
484
644
|
const resume = validateResumeFlag(run, at, path);
|
|
485
645
|
validateRepository(run, "pull_request", at, path);
|
|
486
646
|
const replicas = validateReplicas(run, at, path);
|
|
487
647
|
return {
|
|
488
|
-
on: {
|
|
489
|
-
|
|
648
|
+
on: {
|
|
649
|
+
type: "pull_request",
|
|
650
|
+
action: [...actions],
|
|
651
|
+
// Absent rather than present-and-undefined: an unnarrowed rule's normalized shape must stay
|
|
652
|
+
// byte-identical to the one every pre-#66 trigger file produces.
|
|
653
|
+
...(reviewState !== undefined && { reviewState }),
|
|
654
|
+
any: predicate.any,
|
|
655
|
+
all: predicate.all,
|
|
656
|
+
none: predicate.none,
|
|
657
|
+
},
|
|
658
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
490
659
|
};
|
|
491
660
|
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* The optional `on.reviewState` narrowing (issue #66). Returns the normalized array, or `undefined` when
|
|
664
|
+
* unset, which means every verdict fires.
|
|
665
|
+
*
|
|
666
|
+
* All four refusals are the same call the action vocabulary makes at the top of `normalizePullRequest`: a
|
|
667
|
+
* narrowing that can never apply does not crash anything downstream, it simply sits in the file looking
|
|
668
|
+
* configured while the trigger either fires on everything or on nothing. Refusing at load is what turns
|
|
669
|
+
* that into a message. The `review_submitted` requirement is the sharpest of the four -- a `reviewState`
|
|
670
|
+
* beside `["opened","synchronize"]` reads as "only run for these verdicts" and does the exact opposite.
|
|
671
|
+
*/
|
|
672
|
+
function validateReviewState(on, actions, run, at, path) {
|
|
673
|
+
if (on.reviewState === undefined) return undefined;
|
|
674
|
+
if (run.kind !== "github") {
|
|
675
|
+
throw configError(`${at}: on.reviewState is github-only (got ${run.kind}), no other forge reports a review verdict: ${path}`);
|
|
676
|
+
}
|
|
677
|
+
if (!actions.includes(REVIEW_ACTION)) {
|
|
678
|
+
throw configError(`${at}: on.reviewState requires on.action to include ${JSON.stringify(REVIEW_ACTION)}, otherwise it narrows nothing: ${path}`);
|
|
679
|
+
}
|
|
680
|
+
if (!Array.isArray(on.reviewState) || on.reviewState.length === 0) {
|
|
681
|
+
throw configError(`${at}: on.reviewState must be a non-empty array: ${path}`);
|
|
682
|
+
}
|
|
683
|
+
const expected = [...REVIEW_STATES].join("|");
|
|
684
|
+
for (const s of on.reviewState) {
|
|
685
|
+
if (!REVIEW_STATES.has(s)) {
|
|
686
|
+
throw configError(`${at}: on.reviewState has an unsupported review state ${JSON.stringify(s)} (expected ${expected}): ${path}`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return [...on.reviewState];
|
|
690
|
+
}
|