@edgehero/pi-dispatch 0.1.2 → 0.3.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/package.json +3 -1
- package/src/azure-prompt.mjs +14 -8
- package/src/cli.mjs +2 -2
- package/src/config.mjs +19 -2
- package/src/copy-tree.mjs +215 -0
- package/src/doctor.mjs +242 -8
- package/src/flow-gate.mjs +3 -1
- package/src/forgejo-prompt.mjs +14 -8
- package/src/github-app-setup.mjs +11 -20
- 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/open-browser.mjs +24 -0
- 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 +34 -4
- package/src/queue.mjs +16 -2
- package/src/run-container.mjs +7 -2
- package/src/run-history.mjs +13 -0
- package/src/schedules.mjs +16 -1
- package/src/session-store.mjs +6 -0
- package/src/start.mjs +49 -9
- package/src/triggers.mjs +177 -8
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,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: {
|
|
519
|
-
|
|
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
|
+
}
|