@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/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`. `approved` has no
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
- * Carried on all four kinds for `run.image`'s reason rather than cron-only like `run.github`: continuing a
234
- * conversation is a property of the FLOW, and a cron trigger's flow is a flow. A cron job keys on its own
235
- * scheduler id (session-key.mjs), which is the one key in this feature chosen by nobody untrusted.
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: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
489
- run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas },
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
+ }