@edgehero/pi-dispatch-admin 0.1.3 → 0.1.4
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/README.md +4 -2
- package/dist/index.mjs +191 -43
- package/package.json +4 -2
- package/skills/operate-pi-dispatch/SKILL.md +76 -0
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Every trigger produces the same job, through the same path — one queue, one co
|
|
|
32
32
|
|
|
33
33
|
- **The container is the boundary.** pi ships no permission system, so every job runs `--cap-drop=ALL`, non-root, ephemeral, with its instructions mounted read-only. That *is* the missing permission system — enforced by Docker, not hoped for.
|
|
34
34
|
- **Spend is bounded before tokens.** A per-job turn budget plus daily / weekly / monthly caps, checked *before* a container starts. A runaway or a junk trigger costs you a refusal, not a surprise bill.
|
|
35
|
-
- **Three triggers, one job.** A CLI command, a cron schedule, or a GitHub issue/PR — same queue, same box, same budget. Cron is the unattended one: recurring work on your own hardware, in an image you control.
|
|
35
|
+
- **Three triggers, one job.** A CLI command, a cron schedule, or a GitHub issue/PR — same queue, same box, same budget. Cron is the unattended one: recurring work on your own hardware, in an image you control — one per deployment, or one per trigger.
|
|
36
36
|
- **Your image, your tools.** Bake a project's toolchain into the Dockerfile; it ships **Playwright + Chromium**, so a flow can build a frontend, screenshot it, and iterate on the rendered result.
|
|
37
37
|
|
|
38
38
|
## The console — `/dispatch`
|
|
@@ -45,6 +45,8 @@ This extension puts a live TUI over the whole deployment in one command:
|
|
|
45
45
|
|
|
46
46
|
- **Status & spend.** Queue and worker state, day/week/month **spend meters** + a daily token counter, and a run-history table with per-job token & cost.
|
|
47
47
|
- **Triggers, editable live.** cron / label / comment / pull_request, with colored drill-ins showing *what fires* each one, *what it runs*, and its *trust model* — added, edited, and deleted without a restart.
|
|
48
|
+
- **You can see which triggers run third-party code.** A trigger loads the pi packages you pinned into your global overlay **unless** it carries `run.packages: false`, so the ones that do get a **`[packages]`** badge in the trigger list, and their **trust model** names the staged `name@version` set plus the one-line consequence — third-party code, on adversarial input, with open network egress. Declining is deliberately *not* a panel action: it stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
|
|
49
|
+
- **You can see which image each trigger runs.** A trigger may name its own container image with `run.image` (absent = the deployment default), and the trigger list shows the tag while the drill-in states it either way — because which image a job runs *is* which code it runs. Like `packages`, it is display-only: naming an image stays an edit to the reviewed `triggers.json`, which neither the console nor a model-callable tool will make for you.
|
|
48
50
|
- **Quiet hours.** Scheduled pause windows per folder or repo: defer a scope's runs **between certain times** — recurring daily, weekday- and date-bounded, timezone-aware — and resume automatically. Deferred, never dropped, at zero budget cost.
|
|
49
51
|
- **AI-operable, with a human gate.** Model-callable tools let an agent change limits and manage triggers and pause windows — but **every write pops an operator confirmation the model can't answer**, and is refused when no operator is present. The bundled `operate-pi-dispatch` **skill** teaches the agent to use those gates.
|
|
50
52
|
- **Logs stay put.** Raw container output renders only in the overlay viewer, never into model context.
|
|
@@ -55,7 +57,7 @@ This extension puts a live TUI over the whole deployment in one command:
|
|
|
55
57
|
pi install npm:@edgehero/pi-dispatch-admin
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
Then run pi and open `/dispatch`. Point it at your deployment with `VALKEY_URL` / `PI_LOGS_DIR` / `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` / `PI_PAUSE_WINDOWS_FILE` — the same values your worker uses. It reads that deployment's queue and run history; on its own it will just say "queue unreachable."
|
|
60
|
+
Then run pi and open `/dispatch`. Point it at your deployment with `VALKEY_URL` / `PI_LOGS_DIR` / `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` / `PI_PAUSE_WINDOWS_FILE` / `PI_GLOBAL_PI_DIR` — the same values your worker uses. It reads that deployment's queue and run history; on its own it will just say "queue unreachable."
|
|
59
61
|
|
|
60
62
|
## Get the whole thing
|
|
61
63
|
|
package/dist/index.mjs
CHANGED
|
@@ -4220,7 +4220,7 @@ __export(typebox_exports, {
|
|
|
4220
4220
|
|
|
4221
4221
|
// src/read-model.mjs
|
|
4222
4222
|
import * as nodeFs2 from "node:fs";
|
|
4223
|
-
import { join, delimiter, sep } from "node:path";
|
|
4223
|
+
import { join as join2, delimiter, sep } from "node:path";
|
|
4224
4224
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
4225
4225
|
|
|
4226
4226
|
// ../worker/src/config.mjs
|
|
@@ -4371,8 +4371,12 @@ function windowState(reserved, cap, softHoldPct = null) {
|
|
|
4371
4371
|
|
|
4372
4372
|
// ../worker/src/triggers.mjs
|
|
4373
4373
|
var ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
|
|
4374
|
-
var RUN_KINDS = /* @__PURE__ */ new Set(["local", "github"]);
|
|
4375
|
-
var
|
|
4374
|
+
var RUN_KINDS = /* @__PURE__ */ new Set(["local", "github", "gitlab"]);
|
|
4375
|
+
var FORGE_KINDS = /* @__PURE__ */ new Set(["github", "gitlab"]);
|
|
4376
|
+
var PR_ACTIONS = {
|
|
4377
|
+
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
|
|
4378
|
+
gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"])
|
|
4379
|
+
};
|
|
4376
4380
|
var ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
4377
4381
|
function isNonEmptyString2(value) {
|
|
4378
4382
|
return typeof value === "string" && value.trim() !== "";
|
|
@@ -4388,7 +4392,7 @@ function parseTriggers(text, path) {
|
|
|
4388
4392
|
if (!Array.isArray(entries)) {
|
|
4389
4393
|
throw configError(`triggers file must have a "triggers" array: ${path}`);
|
|
4390
4394
|
}
|
|
4391
|
-
const state = { seenCronIds: /* @__PURE__ */ new Set(),
|
|
4395
|
+
const state = { seenCronIds: /* @__PURE__ */ new Set(), commentCounts: {} };
|
|
4392
4396
|
return entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
|
|
4393
4397
|
}
|
|
4394
4398
|
function normalizeTrigger(entry, index, path, state) {
|
|
@@ -4407,7 +4411,7 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
4407
4411
|
throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
|
|
4408
4412
|
}
|
|
4409
4413
|
if (!RUN_KINDS.has(run.kind)) {
|
|
4410
|
-
throw configError(`${at}: run.kind must be one of local|github (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
4414
|
+
throw configError(`${at}: run.kind must be one of local|github|gitlab (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
4411
4415
|
}
|
|
4412
4416
|
if (on.type === "cron") {
|
|
4413
4417
|
if (run.kind !== "local") {
|
|
@@ -4415,8 +4419,8 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
4415
4419
|
}
|
|
4416
4420
|
return normalizeCron(on, run, index, path, state);
|
|
4417
4421
|
}
|
|
4418
|
-
if (run.kind
|
|
4419
|
-
throw configError(`${at}: a ${on.type} trigger produces a
|
|
4422
|
+
if (!FORGE_KINDS.has(run.kind)) {
|
|
4423
|
+
throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of github|gitlab (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
4420
4424
|
}
|
|
4421
4425
|
if (on.type === "label") return normalizeLabel(on, run, index, path);
|
|
4422
4426
|
if (on.type === "comment") return normalizeComment(on, run, index, path, state);
|
|
@@ -4455,11 +4459,36 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
4455
4459
|
if (!isNonEmptyString2(run.task)) {
|
|
4456
4460
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
4457
4461
|
}
|
|
4462
|
+
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
4463
|
+
throw configError(`cron trigger "${id}": run.github must be true or false when present: ${path}`);
|
|
4464
|
+
}
|
|
4465
|
+
const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
|
|
4466
|
+
const image = validateImageRef(run, `cron trigger "${id}"`, path);
|
|
4458
4467
|
return {
|
|
4459
4468
|
on: { type: "cron", id, pattern },
|
|
4460
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns }
|
|
4469
|
+
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 }
|
|
4461
4470
|
};
|
|
4462
4471
|
}
|
|
4472
|
+
function validatePackagesFlag(run, at, path) {
|
|
4473
|
+
if (run.packages !== void 0 && typeof run.packages !== "boolean") {
|
|
4474
|
+
throw configError(`${at}: run.packages must be true or false when present: ${path}`);
|
|
4475
|
+
}
|
|
4476
|
+
return run.packages;
|
|
4477
|
+
}
|
|
4478
|
+
function validateImageRef(run, at, path) {
|
|
4479
|
+
const image = run.image;
|
|
4480
|
+
if (image === void 0) return void 0;
|
|
4481
|
+
if (typeof image !== "string" || image.trim() === "") {
|
|
4482
|
+
throw configError(`${at}: run.image must be a non-empty string when present: ${path}`);
|
|
4483
|
+
}
|
|
4484
|
+
if (image !== image.trim()) {
|
|
4485
|
+
throw configError(`${at}: run.image must not have leading or trailing whitespace (got ${JSON.stringify(image)}): ${path}`);
|
|
4486
|
+
}
|
|
4487
|
+
if (image.startsWith("-")) {
|
|
4488
|
+
throw configError(`${at}: run.image must not start with "-" -- it is passed as the image positional in the docker argv, where a leading dash parses as a flag (got ${JSON.stringify(image)}): ${path}`);
|
|
4489
|
+
}
|
|
4490
|
+
return image;
|
|
4491
|
+
}
|
|
4463
4492
|
function validatePredicate(on, index, path, requirePositive) {
|
|
4464
4493
|
const at = `trigger at index ${index}`;
|
|
4465
4494
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -4480,7 +4509,9 @@ function normalizeLabel(on, run, index, path) {
|
|
|
4480
4509
|
if (!isNonEmptyString2(run.flow)) {
|
|
4481
4510
|
throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
|
|
4482
4511
|
}
|
|
4483
|
-
|
|
4512
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
4513
|
+
const image = validateImageRef(run, at, path);
|
|
4514
|
+
return { on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none }, run: { kind: run.kind, flow: run.flow, packages, image } };
|
|
4484
4515
|
}
|
|
4485
4516
|
function normalizeComment(on, run, index, path, state) {
|
|
4486
4517
|
const at = `trigger at index ${index}`;
|
|
@@ -4490,11 +4521,13 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
4490
4521
|
if (!isNonEmptyString2(run.flow)) {
|
|
4491
4522
|
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
|
|
4492
4523
|
}
|
|
4493
|
-
state.
|
|
4494
|
-
if (state.
|
|
4495
|
-
throw configError(`${at}: at most one comment trigger is allowed: ${path}`);
|
|
4524
|
+
state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
|
|
4525
|
+
if (state.commentCounts[run.kind] > 1) {
|
|
4526
|
+
throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
|
|
4496
4527
|
}
|
|
4497
|
-
|
|
4528
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
4529
|
+
const image = validateImageRef(run, at, path);
|
|
4530
|
+
return { on: { type: "comment", phrase: on.phrase }, run: { kind: run.kind, flow: run.flow, packages, image } };
|
|
4498
4531
|
}
|
|
4499
4532
|
function normalizePullRequest(on, run, index, path) {
|
|
4500
4533
|
const at = `trigger at index ${index}`;
|
|
@@ -4502,19 +4535,23 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
4502
4535
|
if (!Array.isArray(actions) || actions.length === 0) {
|
|
4503
4536
|
throw configError(`${at}: pull_request on.action must be a non-empty array: ${path}`);
|
|
4504
4537
|
}
|
|
4538
|
+
const allowed = PR_ACTIONS[run.kind];
|
|
4539
|
+
const expected = [...allowed].join("|");
|
|
4505
4540
|
for (const a of actions) {
|
|
4506
|
-
if (!
|
|
4507
|
-
throw configError(`${at}: pull_request on.action has an unsupported action ${JSON.stringify(a)} (expected
|
|
4541
|
+
if (!allowed.has(a)) {
|
|
4542
|
+
throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
4508
4543
|
}
|
|
4509
4544
|
}
|
|
4510
|
-
const requirePositive = actions.includes("labeled");
|
|
4545
|
+
const requirePositive = run.kind === "github" && actions.includes("labeled");
|
|
4511
4546
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
4512
4547
|
if (!isNonEmptyString2(run.flow)) {
|
|
4513
4548
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
|
|
4514
4549
|
}
|
|
4550
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
4551
|
+
const image = validateImageRef(run, at, path);
|
|
4515
4552
|
return {
|
|
4516
4553
|
on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
|
|
4517
|
-
run: { kind:
|
|
4554
|
+
run: { kind: run.kind, flow: run.flow, packages, image }
|
|
4518
4555
|
};
|
|
4519
4556
|
}
|
|
4520
4557
|
|
|
@@ -4690,7 +4727,7 @@ var QUEUE = "pi-jobs";
|
|
|
4690
4727
|
function makeQueue(connection) {
|
|
4691
4728
|
return new Queue(QUEUE, { connection });
|
|
4692
4729
|
}
|
|
4693
|
-
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
4730
|
+
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
4694
4731
|
const minute = now.toISOString().slice(0, 16);
|
|
4695
4732
|
const id = jobId ?? localJobId({ folder, flow, task, minute });
|
|
4696
4733
|
const data = {
|
|
@@ -4701,6 +4738,7 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
|
|
|
4701
4738
|
provider,
|
|
4702
4739
|
model,
|
|
4703
4740
|
maxTurns,
|
|
4741
|
+
...image !== void 0 && { image },
|
|
4704
4742
|
...chainDepth !== void 0 && { chainDepth },
|
|
4705
4743
|
...parentJobId !== void 0 && { parentJobId }
|
|
4706
4744
|
};
|
|
@@ -4764,6 +4802,48 @@ function gitDirty(folder, { exec: exec2 = execFileSync } = {}) {
|
|
|
4764
4802
|
}
|
|
4765
4803
|
}
|
|
4766
4804
|
|
|
4805
|
+
// ../worker/src/packages.mjs
|
|
4806
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4807
|
+
import { join } from "node:path";
|
|
4808
|
+
|
|
4809
|
+
// ../worker/src/import-pi.mjs
|
|
4810
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
4811
|
+
import { promisify as promisify2 } from "node:util";
|
|
4812
|
+
var ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
|
|
4813
|
+
var execFileAsync = promisify2(execFile2);
|
|
4814
|
+
|
|
4815
|
+
// ../worker/src/docker-run.mjs
|
|
4816
|
+
var CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
4817
|
+
|
|
4818
|
+
// ../worker/src/packages.mjs
|
|
4819
|
+
var PACKAGES_SUBDIR = "packages";
|
|
4820
|
+
var STAGE_MANIFEST = "packages.json";
|
|
4821
|
+
var CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
4822
|
+
var MAX_DIR_LENGTH = 64;
|
|
4823
|
+
function isNonEmptyString4(value) {
|
|
4824
|
+
return typeof value === "string" && value.trim() !== "";
|
|
4825
|
+
}
|
|
4826
|
+
function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
4827
|
+
if (!globalPiDir) return null;
|
|
4828
|
+
const path = join(globalPiDir, PACKAGES_SUBDIR, STAGE_MANIFEST);
|
|
4829
|
+
try {
|
|
4830
|
+
if (!fileExists(path)) return null;
|
|
4831
|
+
const parsed = JSON.parse(readFile(path, "utf8"));
|
|
4832
|
+
if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.packages)) return null;
|
|
4833
|
+
const packages = [];
|
|
4834
|
+
for (const entry of parsed.packages) {
|
|
4835
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
4836
|
+
const { name, version, dir } = entry;
|
|
4837
|
+
if (!isNonEmptyString4(name) || !isNonEmptyString4(version) || !isNonEmptyString4(dir)) return null;
|
|
4838
|
+
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
4839
|
+
packages.push({ name, version, dir });
|
|
4840
|
+
}
|
|
4841
|
+
return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
|
|
4842
|
+
} catch {
|
|
4843
|
+
return null;
|
|
4844
|
+
}
|
|
4845
|
+
}
|
|
4846
|
+
|
|
4767
4847
|
// src/read-model.mjs
|
|
4768
4848
|
function resolvePaths(env = process.env) {
|
|
4769
4849
|
return {
|
|
@@ -4772,6 +4852,10 @@ function resolvePaths(env = process.env) {
|
|
|
4772
4852
|
settingsFile: settingsFilePath(env),
|
|
4773
4853
|
triggersPath: env.PI_TRIGGERS_FILE ?? "deploy/triggers.json",
|
|
4774
4854
|
pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "deploy/pause-windows.json",
|
|
4855
|
+
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
|
|
4856
|
+
// packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
|
|
4857
|
+
// normal deployment, in which no trigger can arm any package.
|
|
4858
|
+
globalPiDir: env.PI_GLOBAL_PI_DIR || null,
|
|
4775
4859
|
captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1",
|
|
4776
4860
|
// The two dispatch_run bounds the extension enforces producer-side, read DIRECTLY from env (never
|
|
4777
4861
|
// loadConfig, which throws on unrelated GitHub-auth problems). `delimitedList`/`nonNegativeInt` are
|
|
@@ -5058,19 +5142,19 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
|
|
|
5058
5142
|
const records = [];
|
|
5059
5143
|
for (const name of names) {
|
|
5060
5144
|
if (!name.endsWith(".json")) continue;
|
|
5061
|
-
const record = readJsonFile(
|
|
5145
|
+
const record = readJsonFile(join2(logsDir, name), fs);
|
|
5062
5146
|
if (record && typeof record === "object") records.push(record);
|
|
5063
5147
|
}
|
|
5064
5148
|
records.sort(byEndedAtDesc);
|
|
5065
5149
|
return records.slice(0, cap);
|
|
5066
5150
|
}
|
|
5067
5151
|
function readRun({ logsDir, jobId, fs = nodeFs2 }) {
|
|
5068
|
-
return readJsonFile(
|
|
5152
|
+
return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
|
|
5069
5153
|
}
|
|
5070
5154
|
function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
|
|
5071
5155
|
let text;
|
|
5072
5156
|
try {
|
|
5073
|
-
text = fs.readFileSync(
|
|
5157
|
+
text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
|
|
5074
5158
|
} catch {
|
|
5075
5159
|
return { missing: true };
|
|
5076
5160
|
}
|
|
@@ -5114,6 +5198,9 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5114
5198
|
if (on === null || typeof on !== "object") return null;
|
|
5115
5199
|
const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
|
|
5116
5200
|
const flow = typeof run.flow === "string" ? run.flow : null;
|
|
5201
|
+
const packages = run.packages !== false;
|
|
5202
|
+
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
5203
|
+
const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
|
|
5117
5204
|
switch (on.type) {
|
|
5118
5205
|
case "cron":
|
|
5119
5206
|
return {
|
|
@@ -5124,12 +5211,14 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5124
5211
|
flow,
|
|
5125
5212
|
// Optional per-cron model override (passthrough into job.data); null when the entry resolves the
|
|
5126
5213
|
// deployment default. Surfaced so the drill-in shows which schedules pin their own model.
|
|
5127
|
-
model: typeof run.model === "string" ? run.model : null
|
|
5214
|
+
model: typeof run.model === "string" ? run.model : null,
|
|
5215
|
+
packages,
|
|
5216
|
+
image
|
|
5128
5217
|
};
|
|
5129
5218
|
case "label":
|
|
5130
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow };
|
|
5219
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, forge };
|
|
5131
5220
|
case "comment":
|
|
5132
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow };
|
|
5221
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, forge };
|
|
5133
5222
|
case "pull_request":
|
|
5134
5223
|
return {
|
|
5135
5224
|
type: "pull_request",
|
|
@@ -5137,7 +5226,10 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5137
5226
|
any: normalizeSelector(on.any),
|
|
5138
5227
|
all: normalizeSelector(on.all),
|
|
5139
5228
|
none: normalizeSelector(on.none),
|
|
5140
|
-
flow
|
|
5229
|
+
flow,
|
|
5230
|
+
packages,
|
|
5231
|
+
image,
|
|
5232
|
+
forge
|
|
5141
5233
|
};
|
|
5142
5234
|
default:
|
|
5143
5235
|
return null;
|
|
@@ -5147,6 +5239,28 @@ function normalizeSelector(value) {
|
|
|
5147
5239
|
if (!Array.isArray(value)) return [];
|
|
5148
5240
|
return value.filter((member) => typeof member === "string");
|
|
5149
5241
|
}
|
|
5242
|
+
function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest = readStageManifest } = {}) {
|
|
5243
|
+
const empty = { stagedAt: null, packages: [] };
|
|
5244
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
5245
|
+
let manifest;
|
|
5246
|
+
try {
|
|
5247
|
+
manifest = readManifest({
|
|
5248
|
+
globalPiDir,
|
|
5249
|
+
readFile: (path) => fs.readFileSync(path, "utf8"),
|
|
5250
|
+
fileExists: (path) => fs.existsSync(path)
|
|
5251
|
+
});
|
|
5252
|
+
} catch {
|
|
5253
|
+
return empty;
|
|
5254
|
+
}
|
|
5255
|
+
const entries = Array.isArray(manifest?.packages) ? manifest.packages : [];
|
|
5256
|
+
return {
|
|
5257
|
+
stagedAt: typeof manifest?.stagedAt === "string" ? manifest.stagedAt : null,
|
|
5258
|
+
packages: entries.filter((p) => p && typeof p.name === "string").map(nameAtVersion)
|
|
5259
|
+
};
|
|
5260
|
+
}
|
|
5261
|
+
function nameAtVersion(pkg) {
|
|
5262
|
+
return typeof pkg.version === "string" && pkg.version !== "" ? `${pkg.name}@${pkg.version}` : pkg.name;
|
|
5263
|
+
}
|
|
5150
5264
|
function listRunIds({ logsDir, fs = nodeFs2 }) {
|
|
5151
5265
|
let names;
|
|
5152
5266
|
try {
|
|
@@ -5279,17 +5393,20 @@ function renderTriggers({ schedulers, triggers } = {}) {
|
|
|
5279
5393
|
}
|
|
5280
5394
|
function triggerLine(t) {
|
|
5281
5395
|
const flow = t?.flow ?? "-";
|
|
5396
|
+
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
|
|
5397
|
+
const pkgs = t?.packages === true ? " [packages]" : "";
|
|
5398
|
+
const img = t?.image ? ` [image ${t.image}]` : "";
|
|
5282
5399
|
switch (t?.type) {
|
|
5283
5400
|
case "cron":
|
|
5284
|
-
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}`;
|
|
5401
|
+
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}`;
|
|
5285
5402
|
case "label":
|
|
5286
|
-
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}`;
|
|
5403
|
+
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}`;
|
|
5287
5404
|
case "comment":
|
|
5288
|
-
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}`;
|
|
5405
|
+
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}`;
|
|
5289
5406
|
case "pull_request": {
|
|
5290
5407
|
const clauses = ruleClauses(t);
|
|
5291
5408
|
const action = `action[${(t.action ?? []).join(",")}]`;
|
|
5292
|
-
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}`;
|
|
5409
|
+
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}`;
|
|
5293
5410
|
}
|
|
5294
5411
|
default:
|
|
5295
5412
|
return "(unknown trigger)";
|
|
@@ -5571,6 +5688,11 @@ function createDashboardDeps(paths) {
|
|
|
5571
5688
|
settings: readSettingsView({ settingsFile: paths.settingsFile }),
|
|
5572
5689
|
triggers: readTriggers({ triggersPath: paths.triggersPath }),
|
|
5573
5690
|
pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
|
|
5691
|
+
// The operator's staged third-party pi packages (REQ-GLOBAL-PI-OVERLAY), for the armed triggers'
|
|
5692
|
+
// trust model. Like the four reads above it is a plain file read whose fs access lives entirely in
|
|
5693
|
+
// read-model.mjs -- this module never touches the filesystem -- and it degrades to a safe empty
|
|
5694
|
+
// shape rather than throwing, so a broken overlay cannot take the whole snapshot down.
|
|
5695
|
+
stagedPackages: readStagedPackages({ globalPiDir: paths.globalPiDir }),
|
|
5574
5696
|
// ONLY the id off the active Job -- a Job's `.data` holds issue title/body/username (PII), so it
|
|
5575
5697
|
// never enters the snapshot (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT).
|
|
5576
5698
|
activeJobId: activeList?.[0]?.id ?? null
|
|
@@ -5812,7 +5934,7 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
5812
5934
|
const detailTitle = `trigger \xB7 ${t?.type ?? "?"}`;
|
|
5813
5935
|
const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
|
|
5814
5936
|
const sched = cronSchedInfo(t, snapshot);
|
|
5815
|
-
const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched);
|
|
5937
|
+
const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched, snapshot?.stagedPackages);
|
|
5816
5938
|
if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", "e edit \xB7 x delete \xB7 esc back"];
|
|
5817
5939
|
const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: triggerDetailHints(dw - 4, styler) });
|
|
5818
5940
|
return centerBlock(boxed, Math.trunc(width), dw);
|
|
@@ -5984,7 +6106,10 @@ function triggerRow(t, sel, inner, styler) {
|
|
|
5984
6106
|
const cursor = sel ? styler.fg("accent", "\u203A") : " ";
|
|
5985
6107
|
const kind = t?.type ?? "?";
|
|
5986
6108
|
const badge = styler.cell(kind, KIND_WIDTH, { color: KIND_COLOR[kind] ?? "muted" });
|
|
5987
|
-
|
|
6109
|
+
const forge = t?.forge && t.forge !== "github" ? " " + styler.fg("accent", `[${t.forge}]`) : "";
|
|
6110
|
+
const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
|
|
6111
|
+
const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
|
|
6112
|
+
return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${forge}${pkgs}${img}`, inner, styler);
|
|
5988
6113
|
}
|
|
5989
6114
|
function matchColored(t, styler) {
|
|
5990
6115
|
switch (t?.type) {
|
|
@@ -6084,7 +6209,7 @@ function settingsLines(settings2, inner, styler) {
|
|
|
6084
6209
|
const l2 = [kv("dailyCap", o.dailyCap), tk("dailyTokenCap", o.dailyTokenCap), kv("concurrency", o.concurrency), styler.fg("muted", "softHold") + " " + styler.fg("text", pctVal)].join(gap);
|
|
6085
6210
|
return [fitLine(l1, inner, styler), fitLine(l2, inner, styler)];
|
|
6086
6211
|
}
|
|
6087
|
-
function renderTriggerDetail(t, inner, styler, sched = null) {
|
|
6212
|
+
function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
6088
6213
|
if (!t) return [styler.cell("(no trigger)", inner, { color: "dim" })];
|
|
6089
6214
|
const out = [];
|
|
6090
6215
|
const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
|
|
@@ -6122,15 +6247,25 @@ function renderTriggerDetail(t, inner, styler, sched = null) {
|
|
|
6122
6247
|
out.push(kv("folder", `${t.folder ?? "-"}`, "success"));
|
|
6123
6248
|
out.push(kv("model", t.model ?? "deployment default", t.model ? "accent" : "dim"));
|
|
6124
6249
|
} else {
|
|
6125
|
-
out.push(kv("job", "
|
|
6126
|
-
out.push(kv("target", "the triggering repo#issue / PR", "accent"));
|
|
6250
|
+
out.push(kv("job", t.forge ?? "-", "accent"));
|
|
6251
|
+
out.push(kv("target", t.forge === "gitlab" ? "the triggering project#issue / !MR" : "the triggering repo#issue / PR", "accent"));
|
|
6127
6252
|
out.push(kv("model", "deployment default", "dim"));
|
|
6128
6253
|
}
|
|
6254
|
+
out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
|
|
6129
6255
|
blank();
|
|
6130
6256
|
section("trust model");
|
|
6131
6257
|
for (const line of trustModel(t)) out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("text", line), inner, styler));
|
|
6258
|
+
if (t.packages === true) {
|
|
6259
|
+
const loads = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
|
|
6260
|
+
loads(`packages loaded \xB7 ${stagedNames(staged)}`);
|
|
6261
|
+
loads("third-party code on adversarial input, open network egress");
|
|
6262
|
+
}
|
|
6132
6263
|
return out;
|
|
6133
6264
|
}
|
|
6265
|
+
function stagedNames(staged) {
|
|
6266
|
+
const list = Array.isArray(staged?.packages) ? staged.packages : [];
|
|
6267
|
+
return list.length > 0 ? list.join(" \xB7 ") : "(none staged in the overlay)";
|
|
6268
|
+
}
|
|
6134
6269
|
function trustModel(t) {
|
|
6135
6270
|
switch (t?.type) {
|
|
6136
6271
|
case "cron":
|
|
@@ -6248,6 +6383,10 @@ function renderRunDetail(record, inner, styler, allRuns = []) {
|
|
|
6248
6383
|
out.push(kv("turns", turnBits.join(" \xB7 ")));
|
|
6249
6384
|
const cost = typeof r.tokens?.cost === "number" ? ` \xB7 $${r.tokens.cost.toFixed(4)}` : "";
|
|
6250
6385
|
out.push(kv("tokens", `${show(r.tokens?.total)}${cost}`));
|
|
6386
|
+
const otherTotal = r.tokens?.otherTotal;
|
|
6387
|
+
if (typeof otherTotal === "number" && Number.isFinite(otherTotal) && otherTotal > 0) {
|
|
6388
|
+
out.push(kv("of which", `subagents: ${otherTotal}`, "dim"));
|
|
6389
|
+
}
|
|
6251
6390
|
const children = (Array.isArray(allRuns) ? allRuns : []).filter((x) => x?.parentJobId && r.jobId && x.parentJobId === r.jobId);
|
|
6252
6391
|
const chainBits = [r.parentJobId ? `child of ${r.parentJobId}` : "root"];
|
|
6253
6392
|
if (r.chainDepth !== null && r.chainDepth !== void 0) chainBits.push(`depth ${r.chainDepth}`);
|
|
@@ -6465,11 +6604,12 @@ function registerTools(pi) {
|
|
|
6465
6604
|
pi.registerTool({
|
|
6466
6605
|
name: "dispatch_trigger_add",
|
|
6467
6606
|
label: "pi-dispatch add trigger",
|
|
6468
|
-
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow.
|
|
6607
|
+
description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened, gitlab is open|update|reopen|approved. For webhook triggers the repo and the task come from the triggering issue/PR event \u2014 set only the match + flow \u2014 and they run under the deployment default model.",
|
|
6469
6608
|
executionMode: "sequential",
|
|
6470
6609
|
parameters: typebox_exports.Object({
|
|
6471
6610
|
kind: typebox_exports.String(),
|
|
6472
6611
|
flow: typebox_exports.String(),
|
|
6612
|
+
forge: typebox_exports.Optional(typebox_exports.String()),
|
|
6473
6613
|
id: typebox_exports.Optional(typebox_exports.String()),
|
|
6474
6614
|
pattern: typebox_exports.Optional(typebox_exports.String()),
|
|
6475
6615
|
folder: typebox_exports.Optional(typebox_exports.String()),
|
|
@@ -6685,13 +6825,14 @@ function buildTriggerEntry(kind, f) {
|
|
|
6685
6825
|
if (maxTurns !== void 0) run.maxTurns = maxTurns;
|
|
6686
6826
|
return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
|
|
6687
6827
|
}
|
|
6688
|
-
|
|
6689
|
-
if (kind === "
|
|
6828
|
+
const forge = optStr(f.forge) ?? "github";
|
|
6829
|
+
if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: { kind: forge, flow: f.flow } };
|
|
6830
|
+
if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: { kind: forge, flow: f.flow } };
|
|
6690
6831
|
if (kind === "pull_request") {
|
|
6691
6832
|
const on = { type: "pull_request", action: asWords(f.action) };
|
|
6692
6833
|
const any = asWords(f.labels ?? f.any);
|
|
6693
6834
|
if (any.length > 0) on.any = any;
|
|
6694
|
-
return { on, run: { kind:
|
|
6835
|
+
return { on, run: { kind: forge, flow: f.flow } };
|
|
6695
6836
|
}
|
|
6696
6837
|
return null;
|
|
6697
6838
|
}
|
|
@@ -6965,25 +7106,32 @@ async function addTriggerViaDialogs(paths, ui, notify) {
|
|
|
6965
7106
|
if (maxTurns === void 0) return;
|
|
6966
7107
|
entry = buildTriggerEntry("cron", { id, pattern, folder, flow, task, model, provider, maxTurns });
|
|
6967
7108
|
} else if (kind === "label") {
|
|
6968
|
-
const
|
|
7109
|
+
const forge = await ui.input("forge \u2014 github or gitlab", "github");
|
|
7110
|
+
if (forge === void 0) return;
|
|
7111
|
+
const labels = await ui.input("labels \u2014 space-separated, any-of (a project member applies one to fire)", "pi:fix");
|
|
6969
7112
|
if (labels === void 0) return;
|
|
6970
7113
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the issue text is the task)", "fix");
|
|
6971
7114
|
if (flow === void 0) return;
|
|
6972
|
-
entry = buildTriggerEntry("label", { labels, flow });
|
|
7115
|
+
entry = buildTriggerEntry("label", { labels, flow, forge });
|
|
6973
7116
|
} else if (kind === "comment") {
|
|
7117
|
+
const forge = await ui.input("forge \u2014 github or gitlab", "github");
|
|
7118
|
+
if (forge === void 0) return;
|
|
6974
7119
|
const phrase = await ui.input("trigger phrase \u2014 a comment containing this fires the flow (e.g. @pi)", "@pi");
|
|
6975
7120
|
if (phrase === void 0) return;
|
|
6976
7121
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the comment/issue text is the task)", "fix");
|
|
6977
7122
|
if (flow === void 0) return;
|
|
6978
|
-
entry = buildTriggerEntry("comment", { phrase, flow });
|
|
7123
|
+
entry = buildTriggerEntry("comment", { phrase, flow, forge });
|
|
6979
7124
|
} else {
|
|
6980
|
-
const
|
|
7125
|
+
const forge = await ui.input("forge \u2014 github or gitlab", "github");
|
|
7126
|
+
if (forge === void 0) return;
|
|
7127
|
+
const actionHint = forge === "gitlab" ? "open update reopen approved" : "labeled opened synchronize reopened";
|
|
7128
|
+
const action = await ui.input(`MR/PR actions \u2014 space-separated: ${actionHint}`, forge === "gitlab" ? "update" : "labeled");
|
|
6981
7129
|
if (action === void 0) return;
|
|
6982
7130
|
const labels = await ui.input("labels for 'labeled' \u2014 space-separated (blank for the auto actions)", "pi:review");
|
|
6983
7131
|
if (labels === void 0) return;
|
|
6984
7132
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the PR text is the task)", "review");
|
|
6985
7133
|
if (flow === void 0) return;
|
|
6986
|
-
entry = buildTriggerEntry("pull_request", { action, labels, flow });
|
|
7134
|
+
entry = buildTriggerEntry("pull_request", { action, labels, flow, forge });
|
|
6987
7135
|
}
|
|
6988
7136
|
const res = writeTriggers({ triggersPath: paths.triggersPath, mutate: (list) => [...list, entry] });
|
|
6989
7137
|
notify?.(res.ok ? `trigger added (live) \u2014 ${kind} \u2192 ${entry.run.flow}` : `add rejected: ${res.invalid}`, res.ok ? "info" : "error");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable triggers (cron/label/comment/PR), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub and GitLab triggers (cron/label/comment/PR/MR), and scheduled pause windows — plus AI-operable, human-confirmed controls. Runs against a live pi-dispatch deployment.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"ai-agent",
|
|
12
12
|
"autonomous-agents",
|
|
13
13
|
"self-hosted",
|
|
14
|
+
"github",
|
|
15
|
+
"gitlab",
|
|
14
16
|
"job-queue",
|
|
15
17
|
"cron",
|
|
16
18
|
"tui",
|
|
@@ -49,3 +49,79 @@ Use them like this:
|
|
|
49
49
|
the change can't be made without an operator at the terminal — it is not a bug to work around.
|
|
50
50
|
|
|
51
51
|
The confirm is the approval step. Treat a decline as a final, legitimate answer.
|
|
52
|
+
|
|
53
|
+
## Staged packages — `run.packages`, and why you cannot set it
|
|
54
|
+
|
|
55
|
+
When a trigger fires, the job loads the third-party **pi packages the operator staged** into their global
|
|
56
|
+
overlay dir (`PI_GLOBAL_PI_DIR`, under `packages/`, pinned by version). `run.packages` is an **opt-out**:
|
|
57
|
+
absent and `true` both load them, and only an explicit `run.packages: false` withholds them from that one
|
|
58
|
+
trigger. So the question to answer for a user is never "is this trigger armed" — it is "did this trigger
|
|
59
|
+
decline".
|
|
60
|
+
|
|
61
|
+
It matters because a loading trigger runs pinned third-party code against adversarial input (issue/PR/comment
|
|
62
|
+
text) with open network egress. So the panel makes it visible: a loading trigger is badged `[packages]` in
|
|
63
|
+
the trigger list, and its trust-model drill-in names the staged `name@version` set.
|
|
64
|
+
|
|
65
|
+
**Which packages are staged, and whether a trigger declines them, are both operator edits to reviewed files
|
|
66
|
+
— never a panel action and never a tool call.** This is deliberate, not a gap:
|
|
67
|
+
|
|
68
|
+
- `dispatch_triggers` shows you *whether* a trigger loads them. The `/dispatch` panel displays it too, and
|
|
69
|
+
has no key that sets it.
|
|
70
|
+
- `dispatch_trigger_add` and `dispatch_trigger_edit` **have no `packages` parameter**. You can change it in
|
|
71
|
+
neither direction — you cannot make a trigger load packages and you cannot make one decline them — the
|
|
72
|
+
same reason `dispatch_run` withholds the provider and model from you.
|
|
73
|
+
|
|
74
|
+
So if a user asks you to change a trigger's packages flag, or to stage a package: **say plainly that you
|
|
75
|
+
cannot, and that it is an edit they make to `triggers.json` (and to their overlay dir) themselves.** Do not
|
|
76
|
+
attempt it through `dispatch_trigger_edit`, do not write the triggers file by another route, and do not treat
|
|
77
|
+
the missing parameter as a bug to work around. Reporting which triggers load the staged set and explaining
|
|
78
|
+
the change they would make is the whole of your part.
|
|
79
|
+
|
|
80
|
+
## The job image — `run.image`, and why you cannot set it
|
|
81
|
+
|
|
82
|
+
A trigger may carry `run.image`: the container image that trigger's jobs run in. Absent means the
|
|
83
|
+
deployment default (`PI_JOB_IMAGE`). It exists so one flow can have a Python toolchain and another Node +
|
|
84
|
+
Playwright, without one image carrying the union of both.
|
|
85
|
+
|
|
86
|
+
Report it when asked, and be precise about what it does and does not decide. **Which image a job runs is
|
|
87
|
+
which code it runs** — the pi version, the runner, the guardrail floor and the loader's discovery posture
|
|
88
|
+
all come from the image. What it does *not* decide is what the container may do: `--cap-drop=ALL`, the
|
|
89
|
+
non-root user, the read-only `/job` and the closed env allowlist are built by the worker for every image
|
|
90
|
+
alike. The panel shows the tag in the trigger list and states it in the drill-in either way.
|
|
91
|
+
|
|
92
|
+
**You cannot change it, in either direction.** `dispatch_trigger_add` and `dispatch_trigger_edit` have **no
|
|
93
|
+
`image` parameter**, `dispatch_run` has none, and there is no allowlist for you to consult — because there
|
|
94
|
+
is nothing model-callable to bound. Naming an image is an operator edit to the reviewed `triggers.json`,
|
|
95
|
+
exactly like `run.packages`.
|
|
96
|
+
|
|
97
|
+
So if a user asks you to point a trigger at a different image, or to build one: **say plainly that you
|
|
98
|
+
cannot, and that it is an edit they make to `triggers.json` themselves.** Do not route around it via
|
|
99
|
+
`dispatch_trigger_edit`, which changes the flow only. Two useful things you *can* say: the image must be
|
|
100
|
+
built or pulled on the worker's own host, because jobs run with `--pull=never` and nothing is fetched at
|
|
101
|
+
job time; and `pi-dispatch doctor` lists every image their triggers name and flags one that is missing.
|
|
102
|
+
|
|
103
|
+
## The forge a trigger listens to — `run.kind`
|
|
104
|
+
|
|
105
|
+
A webhook trigger names its forge: `"kind": "github"` or `"kind": "gitlab"`. Everything else about the
|
|
106
|
+
trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`, `image`.
|
|
107
|
+
|
|
108
|
+
Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
|
|
109
|
+
|
|
110
|
+
- **`pull_request` actions are the forge's own words.** GitHub takes
|
|
111
|
+
`labeled | opened | synchronize | reopened`; GitLab takes `open | update | reopen | approved`. A word
|
|
112
|
+
from the wrong forge is refused when the file is written. It would not break anything otherwise — it
|
|
113
|
+
would simply never match an event, and the trigger would look configured while doing nothing.
|
|
114
|
+
- **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
|
|
115
|
+
are fine.
|
|
116
|
+
|
|
117
|
+
`dispatch_trigger_add` takes an optional `forge` parameter, defaulting to `github`. Unlike `image`, this
|
|
118
|
+
one IS offered to the model — a model that can already add a GitHub trigger can already arm a paid run,
|
|
119
|
+
and naming GitLab instead does not widen that. Both paths stay behind the same operator confirm.
|
|
120
|
+
|
|
121
|
+
If you are asked why a GitLab trigger did not fire, the usual answers in order:
|
|
122
|
+
|
|
123
|
+
1. **The actor was not a Developer.** Every GitLab trigger is gated on the actor's project access level,
|
|
124
|
+
including label triggers — a GitLab label is not an approval the way a GitHub one is.
|
|
125
|
+
2. **No label was added by that event.** GitLab has no `labeled` action; the trigger fires on the labels
|
|
126
|
+
an event *added*, so editing an already-labelled issue does nothing. This is deliberate.
|
|
127
|
+
3. **The action word belongs to the other forge.** See above.
|