@edgehero/pi-dispatch 0.1.2 → 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,8 @@ 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 });
186
216
  // RETURNED, not discarded like validateReplicas below, because `resume` still has a legal value on a
187
217
  // cron entry: only `true` is refused (the local path has nothing to resume with), so what survives is
188
218
  // `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
@@ -199,7 +229,7 @@ function normalizeCron(on, run, index, path, state) {
199
229
  // freeze today's default into every stored repeatable.
200
230
  return {
201
231
  on: { type: "cron", id, pattern },
202
- 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 }) },
203
233
  };
204
234
  }
205
235
 
@@ -328,6 +358,98 @@ function validateImageRef(run, at, path) {
328
358
  return image;
329
359
  }
330
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
+
331
453
  /**
332
454
  * Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
333
455
  * BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
@@ -436,12 +558,14 @@ function normalizeLabel(on, run, index, path) {
436
558
  }
437
559
  const packages = validatePackagesFlag(run, at, path);
438
560
  const image = validateImageRef(run, at, path);
561
+ const skillsDir = validateSkillsDir(run, at, path);
562
+ const instructions = validateInstructions(run, at, path);
439
563
  const resume = validateResumeFlag(run, at, path);
440
564
  const repository = validateRepository(run, "label", at, path);
441
565
  const replicas = validateReplicas(run, at, path);
442
566
  return {
443
567
  on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
444
- 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 }) },
445
569
  };
446
570
  }
447
571
 
@@ -462,12 +586,14 @@ function normalizeComment(on, run, index, path, state) {
462
586
  }
463
587
  const packages = validatePackagesFlag(run, at, path);
464
588
  const image = validateImageRef(run, at, path);
589
+ const skillsDir = validateSkillsDir(run, at, path);
590
+ const instructions = validateInstructions(run, at, path);
465
591
  const resume = validateResumeFlag(run, at, path);
466
592
  const repository = validateRepository(run, "comment", at, path);
467
593
  const replicas = validateReplicas(run, at, path);
468
594
  return {
469
595
  on: { type: "comment", phrase: on.phrase },
470
- 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 }) },
471
597
  };
472
598
  }
473
599
 
@@ -488,6 +614,8 @@ function normalizePullRequest(on, run, index, path) {
488
614
  }
489
615
  }
490
616
 
617
+ const reviewState = validateReviewState(on, actions, run, at, path);
618
+
491
619
  // A `labeled` PR trigger is gated by its label predicate (the collaborator-applied label is the
492
620
  // approval), so it MUST carry a positive selector -- exactly as a label trigger does. Auto actions
493
621
  // (opened/synchronize/reopened) are gated by author_association in the filter, so a predicate is
@@ -511,11 +639,52 @@ function normalizePullRequest(on, run, index, path) {
511
639
  }
512
640
  const packages = validatePackagesFlag(run, at, path);
513
641
  const image = validateImageRef(run, at, path);
642
+ const skillsDir = validateSkillsDir(run, at, path);
643
+ const instructions = validateInstructions(run, at, path);
514
644
  const resume = validateResumeFlag(run, at, path);
515
645
  validateRepository(run, "pull_request", at, path);
516
646
  const replicas = validateReplicas(run, at, path);
517
647
  return {
518
- on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
519
- 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 }) },
520
659
  };
521
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
+ }