@edgehero/pi-dispatch-admin 0.1.3 → 0.1.5
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 +6 -4
- package/dist/index.mjs +598 -74
- package/package.json +8 -3
- package/skills/operate-pi-dispatch/SKILL.md +76 -0
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png" alt="pi-dispatch — run the pi coding agent as a self-hosted service" width="880">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png?v=0.4.0" alt="pi-dispatch — run the pi coding agent as a self-hosted service" width="880">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# pi-dispatch — run the pi coding agent as a self-hosted service
|
|
@@ -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`
|
|
@@ -40,11 +40,13 @@ Every trigger produces the same job, through the same path — one queue, one co
|
|
|
40
40
|
This extension puts a live TUI over the whole deployment in one command:
|
|
41
41
|
|
|
42
42
|
<p align="center">
|
|
43
|
-
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png" alt="The /dispatch panel: status, spend meters, triggers, runs, and settings" width="820">
|
|
43
|
+
<img src="https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/dispatch-dashboard.png?v=0.4.0" alt="The /dispatch panel: status, spend meters, triggers, runs, and settings" width="820">
|
|
44
44
|
</p>
|
|
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,15 +4220,92 @@ __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
|
+
// ../worker/src/forges.mjs
|
|
4227
|
+
var FORGES = {
|
|
4228
|
+
github: {
|
|
4229
|
+
jobIdPrefix: "gh-",
|
|
4230
|
+
deliveryIdName: "X-GitHub-Delivery GUID",
|
|
4231
|
+
pullRequestSep: "#",
|
|
4232
|
+
tokenVars: ["GITHUB_TOKEN", "GH_TOKEN"],
|
|
4233
|
+
hostVar: null,
|
|
4234
|
+
prLabelAction: "labeled"
|
|
4235
|
+
},
|
|
4236
|
+
gitlab: {
|
|
4237
|
+
jobIdPrefix: "gl-",
|
|
4238
|
+
deliveryIdName: "webhook-id / Idempotency-Key",
|
|
4239
|
+
pullRequestSep: "!",
|
|
4240
|
+
tokenVars: ["GITLAB_TOKEN", "GL_TOKEN"],
|
|
4241
|
+
hostVar: "GITLAB_HOST",
|
|
4242
|
+
// A label added to a merge request arrives as a plain `update`, indistinguishable from any other
|
|
4243
|
+
// edit, so there is no action for a positive-selector rule to attach to. (Separately, a GitLab label
|
|
4244
|
+
// is not an approval at all -- a Guest can set one at issue creation -- which is why every gitlab
|
|
4245
|
+
// trigger is gated on the actor's resolved access level.)
|
|
4246
|
+
prLabelAction: null
|
|
4247
|
+
},
|
|
4248
|
+
forgejo: {
|
|
4249
|
+
// Forgejo's webhook transport is byte-compatible with GitHub's -- it signs the raw body HMAC-SHA256
|
|
4250
|
+
// and sends X-Hub-Signature-256, X-GitHub-Delivery and X-GitHub-Event. So the delivery id is a GUID
|
|
4251
|
+
// with the same across-retry stability GitHub's has, and REQ-DEDUP-BY-DELIVERY-GUID transfers
|
|
4252
|
+
// unchanged. Only the PREFIX differs, and only so the id spaces stay disjoint.
|
|
4253
|
+
jobIdPrefix: "fj-",
|
|
4254
|
+
deliveryIdName: "X-GitHub-Delivery GUID",
|
|
4255
|
+
// Forgejo numbers issues and pull requests from ONE per-repository sequence, exactly as GitHub does
|
|
4256
|
+
// -- an issue and a PR cannot share an index. So `#` names one thing and no discriminator is needed.
|
|
4257
|
+
pullRequestSep: "#",
|
|
4258
|
+
// `tea` reads GITEA_SERVER_TOKEN; the unprefixed name is what the API examples and most scripts use.
|
|
4259
|
+
tokenVars: ["FORGEJO_TOKEN", "GITEA_SERVER_TOKEN"],
|
|
4260
|
+
hostVar: "FORGEJO_HOST",
|
|
4261
|
+
// Forgejo names the action, so the positive-selector rule applies exactly as it does on GitHub.
|
|
4262
|
+
// Note this is HYGIENE, not the gate: unlike GitHub, every forgejo trigger is additionally gated on
|
|
4263
|
+
// the actor's resolved repository permission -- see filter-forgejo.mjs for why that is not
|
|
4264
|
+
// redundant belt-and-braces but the only claim about Forgejo this project is willing to make.
|
|
4265
|
+
prLabelAction: "label_updated"
|
|
4266
|
+
},
|
|
4267
|
+
azure: {
|
|
4268
|
+
// Azure Service Hooks send NO delivery-id header at all, so this id comes from the body's top-level
|
|
4269
|
+
// `id` GUID -- the one departure `verify-gitlab.mjs` explicitly refuses to make, taken here because
|
|
4270
|
+
// the refusal is right for a forge that HAS a header and inapplicable to one that has none.
|
|
4271
|
+
// (Issue #43 proposes `notificationId`, which is a per-subscription integer sequence: two
|
|
4272
|
+
// subscriptions collide on delivery 1.)
|
|
4273
|
+
jobIdPrefix: "az-",
|
|
4274
|
+
deliveryIdName: "service hook payload id",
|
|
4275
|
+
// Azure numbers work items and pull requests from SEPARATE sequences -- work item ids are
|
|
4276
|
+
// organization-scoped, pull request ids are not -- so `project/repo#123` and `project/repo!123` are
|
|
4277
|
+
// different objects and would collide on one separator, exactly as GitLab's issue #5 and MR !5 do.
|
|
4278
|
+
pullRequestSep: "!",
|
|
4279
|
+
// `az repos` reads AZURE_DEVOPS_EXT_PAT; SYSTEM_ACCESSTOKEN is what a pipeline-shaped script expects.
|
|
4280
|
+
tokenVars: ["AZURE_DEVOPS_EXT_PAT", "SYSTEM_ACCESSTOKEN"],
|
|
4281
|
+
hostVar: "AZURE_DEVOPS_ORG_URL",
|
|
4282
|
+
// Azure attaches no labels to a pull request at all, so there is no action to attach the rule to and
|
|
4283
|
+
// a predicated PR rule could never match. The loader refuses one rather than letting it load dead.
|
|
4284
|
+
prLabelAction: null
|
|
4285
|
+
}
|
|
4286
|
+
};
|
|
4287
|
+
var FORGE_KINDS = Object.freeze(Object.keys(FORGES));
|
|
4288
|
+
var RUN_KINDS = Object.freeze(["local", ...FORGE_KINDS]);
|
|
4289
|
+
function isForgeKind(kind) {
|
|
4290
|
+
return typeof kind === "string" && Object.hasOwn(FORGES, kind);
|
|
4291
|
+
}
|
|
4292
|
+
function forgeSpec(kind) {
|
|
4293
|
+
return isForgeKind(kind) ? FORGES[kind] : void 0;
|
|
4294
|
+
}
|
|
4295
|
+
var MINTED_TOKEN_VARS = new Set(FORGE_KINDS.flatMap((kind) => FORGES[kind].tokenVars));
|
|
4296
|
+
|
|
4226
4297
|
// ../worker/src/config.mjs
|
|
4227
4298
|
function configError(message) {
|
|
4228
4299
|
const error = new Error(message);
|
|
4229
4300
|
error.piDispatchConfig = true;
|
|
4230
4301
|
return error;
|
|
4231
4302
|
}
|
|
4303
|
+
function defaultJobsDir() {
|
|
4304
|
+
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/jobs`.replace(/\\/g, "/");
|
|
4305
|
+
}
|
|
4306
|
+
function defaultSandboxDir(env = process.env) {
|
|
4307
|
+
return `${env.PI_JOBS_DIR ?? defaultJobsDir()}/sandboxes`.replace(/\\/g, "/");
|
|
4308
|
+
}
|
|
4232
4309
|
function defaultLogsDir() {
|
|
4233
4310
|
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
|
|
4234
4311
|
}
|
|
@@ -4371,8 +4448,20 @@ function windowState(reserved, cap, softHoldPct = null) {
|
|
|
4371
4448
|
|
|
4372
4449
|
// ../worker/src/triggers.mjs
|
|
4373
4450
|
var ON_TYPES = /* @__PURE__ */ new Set(["cron", "label", "comment", "pull_request"]);
|
|
4374
|
-
var
|
|
4375
|
-
|
|
4451
|
+
var PR_ACTIONS = {
|
|
4452
|
+
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
|
|
4453
|
+
gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"]),
|
|
4454
|
+
// Forgejo's own spellings. `label_updated` is its `labeled` and `synchronized` its `synchronize` -- a
|
|
4455
|
+
// one-letter difference that an operator would otherwise discover as a trigger that loads clean and
|
|
4456
|
+
// never fires. `label_cleared` is deliberately ABSENT and always will be: REMOVING a label must never
|
|
4457
|
+
// start a paid run, and it has no GitHub counterpart to inherit that rule from.
|
|
4458
|
+
forgejo: /* @__PURE__ */ new Set(["label_updated", "opened", "synchronized", "reopened"]),
|
|
4459
|
+
// Azure's Service Hook events reduced to the two that leave something to act on. `git.pullrequest.merged`
|
|
4460
|
+
// is omitted for the same reason GitLab's `merge` and `close` are: a job started by a merge has nothing
|
|
4461
|
+
// left to do. There is no label action at all -- Azure attaches tags to WORK ITEMS, never to pull
|
|
4462
|
+
// requests -- which is why azure's `prLabelAction` is null and a predicated PR rule is refused below.
|
|
4463
|
+
azure: /* @__PURE__ */ new Set(["created", "updated"])
|
|
4464
|
+
};
|
|
4376
4465
|
var ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
4377
4466
|
function isNonEmptyString2(value) {
|
|
4378
4467
|
return typeof value === "string" && value.trim() !== "";
|
|
@@ -4388,7 +4477,7 @@ function parseTriggers(text, path) {
|
|
|
4388
4477
|
if (!Array.isArray(entries)) {
|
|
4389
4478
|
throw configError(`triggers file must have a "triggers" array: ${path}`);
|
|
4390
4479
|
}
|
|
4391
|
-
const state = { seenCronIds: /* @__PURE__ */ new Set(),
|
|
4480
|
+
const state = { seenCronIds: /* @__PURE__ */ new Set(), commentCounts: {} };
|
|
4392
4481
|
return entries.map((entry, index) => normalizeTrigger(entry, index, path, state));
|
|
4393
4482
|
}
|
|
4394
4483
|
function normalizeTrigger(entry, index, path, state) {
|
|
@@ -4406,8 +4495,8 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
4406
4495
|
if (!ON_TYPES.has(on.type)) {
|
|
4407
4496
|
throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
|
|
4408
4497
|
}
|
|
4409
|
-
if (!RUN_KINDS.
|
|
4410
|
-
throw configError(`${at}: run.kind must be one of
|
|
4498
|
+
if (!RUN_KINDS.includes(run.kind)) {
|
|
4499
|
+
throw configError(`${at}: run.kind must be one of ${RUN_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
4411
4500
|
}
|
|
4412
4501
|
if (on.type === "cron") {
|
|
4413
4502
|
if (run.kind !== "local") {
|
|
@@ -4415,8 +4504,8 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
4415
4504
|
}
|
|
4416
4505
|
return normalizeCron(on, run, index, path, state);
|
|
4417
4506
|
}
|
|
4418
|
-
if (run.kind
|
|
4419
|
-
throw configError(`${at}: a ${on.type} trigger produces a
|
|
4507
|
+
if (!isForgeKind(run.kind)) {
|
|
4508
|
+
throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
4420
4509
|
}
|
|
4421
4510
|
if (on.type === "label") return normalizeLabel(on, run, index, path);
|
|
4422
4511
|
if (on.type === "comment") return normalizeComment(on, run, index, path, state);
|
|
@@ -4455,11 +4544,43 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
4455
4544
|
if (!isNonEmptyString2(run.task)) {
|
|
4456
4545
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
4457
4546
|
}
|
|
4547
|
+
if (run.github !== void 0 && typeof run.github !== "boolean") {
|
|
4548
|
+
throw configError(`cron trigger "${id}": run.github must be true or false when present: ${path}`);
|
|
4549
|
+
}
|
|
4550
|
+
const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
|
|
4551
|
+
const image = validateImageRef(run, `cron trigger "${id}"`, path);
|
|
4552
|
+
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
4458
4553
|
return {
|
|
4459
4554
|
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 }
|
|
4555
|
+
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 }
|
|
4461
4556
|
};
|
|
4462
4557
|
}
|
|
4558
|
+
function validatePackagesFlag(run, at, path) {
|
|
4559
|
+
if (run.packages !== void 0 && typeof run.packages !== "boolean") {
|
|
4560
|
+
throw configError(`${at}: run.packages must be true or false when present: ${path}`);
|
|
4561
|
+
}
|
|
4562
|
+
return run.packages;
|
|
4563
|
+
}
|
|
4564
|
+
function validateResumeFlag(run, at, path) {
|
|
4565
|
+
if (run.resume !== void 0 && typeof run.resume !== "boolean") {
|
|
4566
|
+
throw configError(`${at}: run.resume must be true or false when present: ${path}`);
|
|
4567
|
+
}
|
|
4568
|
+
return run.resume;
|
|
4569
|
+
}
|
|
4570
|
+
function validateImageRef(run, at, path) {
|
|
4571
|
+
const image = run.image;
|
|
4572
|
+
if (image === void 0) return void 0;
|
|
4573
|
+
if (typeof image !== "string" || image.trim() === "") {
|
|
4574
|
+
throw configError(`${at}: run.image must be a non-empty string when present: ${path}`);
|
|
4575
|
+
}
|
|
4576
|
+
if (image !== image.trim()) {
|
|
4577
|
+
throw configError(`${at}: run.image must not have leading or trailing whitespace (got ${JSON.stringify(image)}): ${path}`);
|
|
4578
|
+
}
|
|
4579
|
+
if (image.startsWith("-")) {
|
|
4580
|
+
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}`);
|
|
4581
|
+
}
|
|
4582
|
+
return image;
|
|
4583
|
+
}
|
|
4463
4584
|
function validatePredicate(on, index, path, requirePositive) {
|
|
4464
4585
|
const at = `trigger at index ${index}`;
|
|
4465
4586
|
for (const key of ["any", "all", "none"]) {
|
|
@@ -4474,13 +4595,35 @@ function validatePredicate(on, index, path, requirePositive) {
|
|
|
4474
4595
|
}
|
|
4475
4596
|
return { any: on.any, all: on.all, none: on.none };
|
|
4476
4597
|
}
|
|
4598
|
+
function validateRepository(run, onType, at, path) {
|
|
4599
|
+
const needed = run.kind === "azure" && (onType === "label" || onType === "comment");
|
|
4600
|
+
const raw = run.repository;
|
|
4601
|
+
if (raw === void 0) {
|
|
4602
|
+
if (!needed) return void 0;
|
|
4603
|
+
throw configError(`${at}: an azure ${onType} trigger must set run.repository -- a work item belongs to a project, not a repository, so nothing in the delivery says where to clone: ${path}`);
|
|
4604
|
+
}
|
|
4605
|
+
if (!needed) {
|
|
4606
|
+
throw configError(`${at}: run.repository is only meaningful on an azure label or comment trigger (a pull request names its own repository): ${path}`);
|
|
4607
|
+
}
|
|
4608
|
+
if (!isNonEmptyString2(raw) || raw !== raw.trim() || raw.includes("/")) {
|
|
4609
|
+
throw configError(`${at}: run.repository must be a non-empty repository NAME within the project, with no slashes and no surrounding whitespace (got ${JSON.stringify(raw)}): ${path}`);
|
|
4610
|
+
}
|
|
4611
|
+
return raw;
|
|
4612
|
+
}
|
|
4477
4613
|
function normalizeLabel(on, run, index, path) {
|
|
4478
4614
|
const at = `trigger at index ${index}`;
|
|
4479
4615
|
const predicate = validatePredicate(on, index, path, true);
|
|
4480
4616
|
if (!isNonEmptyString2(run.flow)) {
|
|
4481
4617
|
throw configError(`${at}: label trigger run.flow must be a non-empty string: ${path}`);
|
|
4482
4618
|
}
|
|
4483
|
-
|
|
4619
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
4620
|
+
const image = validateImageRef(run, at, path);
|
|
4621
|
+
const resume = validateResumeFlag(run, at, path);
|
|
4622
|
+
const repository = validateRepository(run, "label", at, path);
|
|
4623
|
+
return {
|
|
4624
|
+
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
4625
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, ...repository !== void 0 && { repository } }
|
|
4626
|
+
};
|
|
4484
4627
|
}
|
|
4485
4628
|
function normalizeComment(on, run, index, path, state) {
|
|
4486
4629
|
const at = `trigger at index ${index}`;
|
|
@@ -4490,11 +4633,18 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
4490
4633
|
if (!isNonEmptyString2(run.flow)) {
|
|
4491
4634
|
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string: ${path}`);
|
|
4492
4635
|
}
|
|
4493
|
-
state.
|
|
4494
|
-
if (state.
|
|
4495
|
-
throw configError(`${at}: at most one comment trigger is allowed: ${path}`);
|
|
4636
|
+
state.commentCounts[run.kind] = (state.commentCounts[run.kind] ?? 0) + 1;
|
|
4637
|
+
if (state.commentCounts[run.kind] > 1) {
|
|
4638
|
+
throw configError(`${at}: at most one ${run.kind} comment trigger is allowed: ${path}`);
|
|
4496
4639
|
}
|
|
4497
|
-
|
|
4640
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
4641
|
+
const image = validateImageRef(run, at, path);
|
|
4642
|
+
const resume = validateResumeFlag(run, at, path);
|
|
4643
|
+
const repository = validateRepository(run, "comment", at, path);
|
|
4644
|
+
return {
|
|
4645
|
+
on: { type: "comment", phrase: on.phrase },
|
|
4646
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, ...repository !== void 0 && { repository } }
|
|
4647
|
+
};
|
|
4498
4648
|
}
|
|
4499
4649
|
function normalizePullRequest(on, run, index, path) {
|
|
4500
4650
|
const at = `trigger at index ${index}`;
|
|
@@ -4502,19 +4652,29 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
4502
4652
|
if (!Array.isArray(actions) || actions.length === 0) {
|
|
4503
4653
|
throw configError(`${at}: pull_request on.action must be a non-empty array: ${path}`);
|
|
4504
4654
|
}
|
|
4655
|
+
const allowed = PR_ACTIONS[run.kind];
|
|
4656
|
+
const expected = [...allowed].join("|");
|
|
4505
4657
|
for (const a of actions) {
|
|
4506
|
-
if (!
|
|
4507
|
-
throw configError(`${at}: pull_request on.action has an unsupported action ${JSON.stringify(a)} (expected
|
|
4658
|
+
if (!allowed.has(a)) {
|
|
4659
|
+
throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
4508
4660
|
}
|
|
4509
4661
|
}
|
|
4510
|
-
const
|
|
4662
|
+
const labelAction = forgeSpec(run.kind)?.prLabelAction;
|
|
4663
|
+
const requirePositive = typeof labelAction === "string" && actions.includes(labelAction);
|
|
4664
|
+
if (run.kind === "azure" && (on.any !== void 0 || on.all !== void 0 || on.none !== void 0)) {
|
|
4665
|
+
throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
|
|
4666
|
+
}
|
|
4511
4667
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
4512
4668
|
if (!isNonEmptyString2(run.flow)) {
|
|
4513
4669
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
|
|
4514
4670
|
}
|
|
4671
|
+
const packages = validatePackagesFlag(run, at, path);
|
|
4672
|
+
const image = validateImageRef(run, at, path);
|
|
4673
|
+
const resume = validateResumeFlag(run, at, path);
|
|
4674
|
+
validateRepository(run, "pull_request", at, path);
|
|
4515
4675
|
return {
|
|
4516
4676
|
on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
|
|
4517
|
-
run: { kind:
|
|
4677
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume }
|
|
4518
4678
|
};
|
|
4519
4679
|
}
|
|
4520
4680
|
|
|
@@ -4690,7 +4850,7 @@ var QUEUE = "pi-jobs";
|
|
|
4690
4850
|
function makeQueue(connection) {
|
|
4691
4851
|
return new Queue(QUEUE, { connection });
|
|
4692
4852
|
}
|
|
4693
|
-
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
4853
|
+
async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
4694
4854
|
const minute = now.toISOString().slice(0, 16);
|
|
4695
4855
|
const id = jobId ?? localJobId({ folder, flow, task, minute });
|
|
4696
4856
|
const data = {
|
|
@@ -4701,6 +4861,7 @@ async function enqueueLocalJob(queue, { folder, flow, task, provider, model, max
|
|
|
4701
4861
|
provider,
|
|
4702
4862
|
model,
|
|
4703
4863
|
maxTurns,
|
|
4864
|
+
...image !== void 0 && { image },
|
|
4704
4865
|
...chainDepth !== void 0 && { chainDepth },
|
|
4705
4866
|
...parentJobId !== void 0 && { parentJobId }
|
|
4706
4867
|
};
|
|
@@ -4755,15 +4916,111 @@ async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
|
4755
4916
|
|
|
4756
4917
|
// ../worker/src/git-dirty.mjs
|
|
4757
4918
|
import { execFileSync } from "node:child_process";
|
|
4758
|
-
function gitDirty(folder, { exec:
|
|
4919
|
+
function gitDirty(folder, { exec: exec3 = execFileSync } = {}) {
|
|
4759
4920
|
try {
|
|
4760
|
-
const out =
|
|
4921
|
+
const out = exec3("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
|
|
4761
4922
|
return out.trim().length > 0;
|
|
4762
4923
|
} catch {
|
|
4763
4924
|
return null;
|
|
4764
4925
|
}
|
|
4765
4926
|
}
|
|
4766
4927
|
|
|
4928
|
+
// ../worker/src/packages.mjs
|
|
4929
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4930
|
+
import { join } from "node:path";
|
|
4931
|
+
|
|
4932
|
+
// ../worker/src/import-pi.mjs
|
|
4933
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
4934
|
+
import { promisify as promisify2 } from "node:util";
|
|
4935
|
+
var ENTRY_NAME_RE = /^[a-z0-9](?:[a-z0-9_.-]{0,62}[a-z0-9])?$/i;
|
|
4936
|
+
var execFileAsync = promisify2(execFile2);
|
|
4937
|
+
|
|
4938
|
+
// ../worker/src/docker-run.mjs
|
|
4939
|
+
var CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
4940
|
+
var CONTAINER_SESSION_DIR = "/session";
|
|
4941
|
+
var CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
4942
|
+
var ISOLATION_FLAGS = [
|
|
4943
|
+
// The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
|
|
4944
|
+
// a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
|
|
4945
|
+
// image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
|
|
4946
|
+
// this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
|
|
4947
|
+
// PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
|
|
4948
|
+
// install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
|
|
4949
|
+
// no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
|
|
4950
|
+
// part that cannot be raced.
|
|
4951
|
+
"--pull=never",
|
|
4952
|
+
"--rm",
|
|
4953
|
+
// ephemeral: gone after the run
|
|
4954
|
+
"--init",
|
|
4955
|
+
// reap zombies (Chromium spawns many); node is PID 1 and does not reap
|
|
4956
|
+
"--cap-drop=ALL",
|
|
4957
|
+
// pi would otherwise inherit the launching user's capabilities
|
|
4958
|
+
"--security-opt",
|
|
4959
|
+
"no-new-privileges",
|
|
4960
|
+
"--pids-limit=512",
|
|
4961
|
+
// bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
|
|
4962
|
+
"--shm-size=1g"
|
|
4963
|
+
// Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
|
|
4964
|
+
];
|
|
4965
|
+
function buildDockerRunArgs({
|
|
4966
|
+
image,
|
|
4967
|
+
env,
|
|
4968
|
+
jobDir,
|
|
4969
|
+
workspace,
|
|
4970
|
+
outboxDir,
|
|
4971
|
+
sessionDir,
|
|
4972
|
+
globalPiDir,
|
|
4973
|
+
name,
|
|
4974
|
+
memory = "4g",
|
|
4975
|
+
cpus = "2",
|
|
4976
|
+
extraFlags = []
|
|
4977
|
+
}) {
|
|
4978
|
+
if (!image) throw new Error("docker run: image is required");
|
|
4979
|
+
if (!name) throw new Error("docker run: container name is required");
|
|
4980
|
+
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
4981
|
+
const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
|
|
4982
|
+
for (const [k, v] of Object.entries(env ?? {})) {
|
|
4983
|
+
if (v === void 0 || v === null) continue;
|
|
4984
|
+
args.push("-e", `${k}=${v}`);
|
|
4985
|
+
}
|
|
4986
|
+
if (jobDir) args.push("-v", `${jobDir}:/job:ro`);
|
|
4987
|
+
args.push("-v", `${workspace}:/workspace`);
|
|
4988
|
+
if (outboxDir) args.push("-v", `${outboxDir}:/outbox`);
|
|
4989
|
+
if (sessionDir) args.push("-v", `${sessionDir}:${CONTAINER_SESSION_DIR}`);
|
|
4990
|
+
if (globalPiDir) args.push("-v", `${globalPiDir}:${CONTAINER_GLOBAL_PI_DIR}:ro`);
|
|
4991
|
+
args.push(image);
|
|
4992
|
+
return args;
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4995
|
+
// ../worker/src/packages.mjs
|
|
4996
|
+
var PACKAGES_SUBDIR = "packages";
|
|
4997
|
+
var STAGE_MANIFEST = "packages.json";
|
|
4998
|
+
var CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
4999
|
+
var MAX_DIR_LENGTH = 64;
|
|
5000
|
+
function isNonEmptyString4(value) {
|
|
5001
|
+
return typeof value === "string" && value.trim() !== "";
|
|
5002
|
+
}
|
|
5003
|
+
function readStageManifest({ globalPiDir, readFile = readFileSync, fileExists = existsSync } = {}) {
|
|
5004
|
+
if (!globalPiDir) return null;
|
|
5005
|
+
const path = join(globalPiDir, PACKAGES_SUBDIR, STAGE_MANIFEST);
|
|
5006
|
+
try {
|
|
5007
|
+
if (!fileExists(path)) return null;
|
|
5008
|
+
const parsed = JSON.parse(readFile(path, "utf8"));
|
|
5009
|
+
if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.packages)) return null;
|
|
5010
|
+
const packages = [];
|
|
5011
|
+
for (const entry of parsed.packages) {
|
|
5012
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
5013
|
+
const { name, version, dir } = entry;
|
|
5014
|
+
if (!isNonEmptyString4(name) || !isNonEmptyString4(version) || !isNonEmptyString4(dir)) return null;
|
|
5015
|
+
if (!ENTRY_NAME_RE.test(dir) || dir.length > MAX_DIR_LENGTH) return null;
|
|
5016
|
+
packages.push({ name, version, dir });
|
|
5017
|
+
}
|
|
5018
|
+
return { stagedAt: typeof parsed.stagedAt === "string" ? parsed.stagedAt : null, packages };
|
|
5019
|
+
} catch {
|
|
5020
|
+
return null;
|
|
5021
|
+
}
|
|
5022
|
+
}
|
|
5023
|
+
|
|
4767
5024
|
// src/read-model.mjs
|
|
4768
5025
|
function resolvePaths(env = process.env) {
|
|
4769
5026
|
return {
|
|
@@ -4772,7 +5029,18 @@ function resolvePaths(env = process.env) {
|
|
|
4772
5029
|
settingsFile: settingsFilePath(env),
|
|
4773
5030
|
triggersPath: env.PI_TRIGGERS_FILE ?? "deploy/triggers.json",
|
|
4774
5031
|
pauseWindowsPath: env.PI_PAUSE_WINDOWS_FILE ?? "deploy/pause-windows.json",
|
|
5032
|
+
// The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY), where the staged third-party pi
|
|
5033
|
+
// packages live under `packages/`. `|| null` so unset AND empty both read as "no overlay" -- the
|
|
5034
|
+
// normal deployment, in which no trigger can arm any package.
|
|
5035
|
+
globalPiDir: env.PI_GLOBAL_PI_DIR || null,
|
|
4775
5036
|
captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1",
|
|
5037
|
+
// REQ-RESURRECTABLE-SANDBOX: where finished runs' directories are retained, and for how long. Read
|
|
5038
|
+
// from env for the same reason as everything above -- never loadConfig. The `0` here means retention
|
|
5039
|
+
// OFF (the panel then says so rather than offering a key that always refuses), which is the opposite
|
|
5040
|
+
// of the log/session sentinels; worker/src/config.mjs carries the full reasoning.
|
|
5041
|
+
sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
|
|
5042
|
+
sandboxRetentionHours: parseNonNegInt(env.PI_SANDBOX_RETENTION_HOURS, 24),
|
|
5043
|
+
sandboxIdleMinutes: parseNonNegInt(env.PI_SANDBOX_IDLE_MINUTES, 30),
|
|
4776
5044
|
// The two dispatch_run bounds the extension enforces producer-side, read DIRECTLY from env (never
|
|
4777
5045
|
// loadConfig, which throws on unrelated GitHub-auth problems). `delimitedList`/`nonNegativeInt` are
|
|
4778
5046
|
// private to the worker's config, so the same shapes are reimplemented here. Default roots [] fails
|
|
@@ -4820,9 +5088,9 @@ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConne
|
|
|
4820
5088
|
}
|
|
4821
5089
|
}
|
|
4822
5090
|
var DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
|
|
4823
|
-
function revParseHead(folder, { exec:
|
|
5091
|
+
function revParseHead(folder, { exec: exec3 = execFileSync2 } = {}) {
|
|
4824
5092
|
try {
|
|
4825
|
-
const out =
|
|
5093
|
+
const out = exec3("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
4826
5094
|
const sha = out.trim();
|
|
4827
5095
|
return sha.length > 0 ? sha : null;
|
|
4828
5096
|
} catch {
|
|
@@ -5058,19 +5326,19 @@ function listRuns({ logsDir, limit = 10, fs = nodeFs2 }) {
|
|
|
5058
5326
|
const records = [];
|
|
5059
5327
|
for (const name of names) {
|
|
5060
5328
|
if (!name.endsWith(".json")) continue;
|
|
5061
|
-
const record = readJsonFile(
|
|
5329
|
+
const record = readJsonFile(join2(logsDir, name), fs);
|
|
5062
5330
|
if (record && typeof record === "object") records.push(record);
|
|
5063
5331
|
}
|
|
5064
5332
|
records.sort(byEndedAtDesc);
|
|
5065
5333
|
return records.slice(0, cap);
|
|
5066
5334
|
}
|
|
5067
5335
|
function readRun({ logsDir, jobId, fs = nodeFs2 }) {
|
|
5068
|
-
return readJsonFile(
|
|
5336
|
+
return readJsonFile(join2(logsDir, `${sanitizeJobId(jobId)}.json`), fs);
|
|
5069
5337
|
}
|
|
5070
5338
|
function readLogTail({ logsDir, jobId, lines = 200, fs = nodeFs2 }) {
|
|
5071
5339
|
let text;
|
|
5072
5340
|
try {
|
|
5073
|
-
text = fs.readFileSync(
|
|
5341
|
+
text = fs.readFileSync(join2(logsDir, `${sanitizeJobId(jobId)}.log`), "utf8");
|
|
5074
5342
|
} catch {
|
|
5075
5343
|
return { missing: true };
|
|
5076
5344
|
}
|
|
@@ -5114,6 +5382,10 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5114
5382
|
if (on === null || typeof on !== "object") return null;
|
|
5115
5383
|
const run = entry.run !== null && typeof entry.run === "object" ? entry.run : {};
|
|
5116
5384
|
const flow = typeof run.flow === "string" ? run.flow : null;
|
|
5385
|
+
const packages = run.packages !== false;
|
|
5386
|
+
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
5387
|
+
const resume = run.resume === true;
|
|
5388
|
+
const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
|
|
5117
5389
|
switch (on.type) {
|
|
5118
5390
|
case "cron":
|
|
5119
5391
|
return {
|
|
@@ -5124,12 +5396,15 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5124
5396
|
flow,
|
|
5125
5397
|
// Optional per-cron model override (passthrough into job.data); null when the entry resolves the
|
|
5126
5398
|
// deployment default. Surfaced so the drill-in shows which schedules pin their own model.
|
|
5127
|
-
model: typeof run.model === "string" ? run.model : null
|
|
5399
|
+
model: typeof run.model === "string" ? run.model : null,
|
|
5400
|
+
packages,
|
|
5401
|
+
image,
|
|
5402
|
+
resume
|
|
5128
5403
|
};
|
|
5129
5404
|
case "label":
|
|
5130
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow };
|
|
5405
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, forge };
|
|
5131
5406
|
case "comment":
|
|
5132
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow };
|
|
5407
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, forge };
|
|
5133
5408
|
case "pull_request":
|
|
5134
5409
|
return {
|
|
5135
5410
|
type: "pull_request",
|
|
@@ -5137,7 +5412,11 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5137
5412
|
any: normalizeSelector(on.any),
|
|
5138
5413
|
all: normalizeSelector(on.all),
|
|
5139
5414
|
none: normalizeSelector(on.none),
|
|
5140
|
-
flow
|
|
5415
|
+
flow,
|
|
5416
|
+
packages,
|
|
5417
|
+
image,
|
|
5418
|
+
resume,
|
|
5419
|
+
forge
|
|
5141
5420
|
};
|
|
5142
5421
|
default:
|
|
5143
5422
|
return null;
|
|
@@ -5147,6 +5426,28 @@ function normalizeSelector(value) {
|
|
|
5147
5426
|
if (!Array.isArray(value)) return [];
|
|
5148
5427
|
return value.filter((member) => typeof member === "string");
|
|
5149
5428
|
}
|
|
5429
|
+
function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
|
|
5430
|
+
const empty = { stagedAt: null, packages: [] };
|
|
5431
|
+
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
5432
|
+
let manifest;
|
|
5433
|
+
try {
|
|
5434
|
+
manifest = readManifest2({
|
|
5435
|
+
globalPiDir,
|
|
5436
|
+
readFile: (path) => fs.readFileSync(path, "utf8"),
|
|
5437
|
+
fileExists: (path) => fs.existsSync(path)
|
|
5438
|
+
});
|
|
5439
|
+
} catch {
|
|
5440
|
+
return empty;
|
|
5441
|
+
}
|
|
5442
|
+
const entries = Array.isArray(manifest?.packages) ? manifest.packages : [];
|
|
5443
|
+
return {
|
|
5444
|
+
stagedAt: typeof manifest?.stagedAt === "string" ? manifest.stagedAt : null,
|
|
5445
|
+
packages: entries.filter((p) => p && typeof p.name === "string").map(nameAtVersion)
|
|
5446
|
+
};
|
|
5447
|
+
}
|
|
5448
|
+
function nameAtVersion(pkg) {
|
|
5449
|
+
return typeof pkg.version === "string" && pkg.version !== "" ? `${pkg.name}@${pkg.version}` : pkg.name;
|
|
5450
|
+
}
|
|
5150
5451
|
function listRunIds({ logsDir, fs = nodeFs2 }) {
|
|
5151
5452
|
let names;
|
|
5152
5453
|
try {
|
|
@@ -5186,6 +5487,75 @@ function clampLines(lines) {
|
|
|
5186
5487
|
return Math.min(2e3, Math.max(1, n));
|
|
5187
5488
|
}
|
|
5188
5489
|
|
|
5490
|
+
// ../worker/src/sandbox.mjs
|
|
5491
|
+
import { execFile as execFile3, spawn } from "node:child_process";
|
|
5492
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
5493
|
+
import { promisify as promisify3 } from "node:util";
|
|
5494
|
+
|
|
5495
|
+
// ../worker/src/sandbox-store.mjs
|
|
5496
|
+
import { lstatSync, mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5497
|
+
import { isAbsolute, join as join3, relative } from "node:path";
|
|
5498
|
+
var SANDBOX_MANIFEST = "manifest.json";
|
|
5499
|
+
var defaultFs = { lstatSync, mkdirSync, readFileSync: readFileSync2, readdirSync, renameSync, rmSync, writeFileSync };
|
|
5500
|
+
function readManifest({ sandboxDir, jobId, fs = defaultFs }) {
|
|
5501
|
+
if (!sandboxDir || jobId === void 0 || jobId === null) return null;
|
|
5502
|
+
const dir = join3(sandboxDir, sanitizeJobId(jobId));
|
|
5503
|
+
try {
|
|
5504
|
+
const manifest = JSON.parse(fs.readFileSync(join3(dir, SANDBOX_MANIFEST), "utf8"));
|
|
5505
|
+
return { ...manifest, dir };
|
|
5506
|
+
} catch {
|
|
5507
|
+
return null;
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
|
|
5511
|
+
// ../worker/src/sandbox.mjs
|
|
5512
|
+
var exec2 = promisify3(execFile3);
|
|
5513
|
+
var SANDBOX_NAME_PREFIX = "pi-sandbox-";
|
|
5514
|
+
function sandboxContainerName(jobId) {
|
|
5515
|
+
return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
|
|
5516
|
+
}
|
|
5517
|
+
function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
|
|
5518
|
+
return buildDockerRunArgs({
|
|
5519
|
+
image,
|
|
5520
|
+
name,
|
|
5521
|
+
workspace,
|
|
5522
|
+
jobDir,
|
|
5523
|
+
// The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
|
|
5524
|
+
// forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
|
|
5525
|
+
// disabled idle timeout emits nothing rather than an empty string.
|
|
5526
|
+
env: {
|
|
5527
|
+
TERM: term || void 0,
|
|
5528
|
+
TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0
|
|
5529
|
+
},
|
|
5530
|
+
// Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
|
|
5531
|
+
// the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
|
|
5532
|
+
// none, so `bash` runs bare and `-it` makes it interactive, in the baked WORKDIR as the baked
|
|
5533
|
+
// non-root USER.
|
|
5534
|
+
extraFlags: ["-i", "-t", "--entrypoint", "bash", ...publish]
|
|
5535
|
+
});
|
|
5536
|
+
}
|
|
5537
|
+
function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, fileExists = existsSync2 }) {
|
|
5538
|
+
if (!jobId) return { refused: "no-job-id", message: "a job id is required (see `pi-dispatch sandbox --list`)" };
|
|
5539
|
+
const manifest = readManifest({ sandboxDir, jobId, ...fs ? { fs } : {} });
|
|
5540
|
+
if (!manifest) {
|
|
5541
|
+
return retentionHours === 0 ? { refused: "retention-off", message: "workspace retention is off \u2014 set PI_SANDBOX_RETENTION_HOURS to a positive number to make future runs resurrectable" } : { refused: "absent", message: `no retained workspace for ${jobId} \u2014 it was swept after ${retentionHours}h, or the run predates retention (\`pi-dispatch sandbox --list\` shows what is left)` };
|
|
5542
|
+
}
|
|
5543
|
+
if (!manifest.image) {
|
|
5544
|
+
return { refused: "no-image", message: `the manifest for ${jobId} names no image, so the sandbox cannot reproduce the run` };
|
|
5545
|
+
}
|
|
5546
|
+
if (!manifest.workspace || !fileExists(manifest.workspace)) {
|
|
5547
|
+
return { refused: "workspace-gone", message: `the workspace for ${jobId} is no longer at ${manifest.workspace} \u2014 a local folder that moved cannot be re-opened` };
|
|
5548
|
+
}
|
|
5549
|
+
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
5550
|
+
}
|
|
5551
|
+
function launchSandbox({ args, spawnFn = spawn }) {
|
|
5552
|
+
return new Promise((resolve) => {
|
|
5553
|
+
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
5554
|
+
child.on("error", (err) => resolve({ code: null, error: err }));
|
|
5555
|
+
child.on("close", (code) => resolve({ code }));
|
|
5556
|
+
});
|
|
5557
|
+
}
|
|
5558
|
+
|
|
5189
5559
|
// src/render.mjs
|
|
5190
5560
|
var SETTINGS_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
|
|
5191
5561
|
var BUDGET_WINDOWS = [
|
|
@@ -5279,17 +5649,21 @@ function renderTriggers({ schedulers, triggers } = {}) {
|
|
|
5279
5649
|
}
|
|
5280
5650
|
function triggerLine(t) {
|
|
5281
5651
|
const flow = t?.flow ?? "-";
|
|
5652
|
+
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
|
|
5653
|
+
const pkgs = t?.packages === true ? " [packages]" : "";
|
|
5654
|
+
const img = t?.image ? ` [image ${t.image}]` : "";
|
|
5655
|
+
const res = t?.resume === true ? " [resume]" : "";
|
|
5282
5656
|
switch (t?.type) {
|
|
5283
5657
|
case "cron":
|
|
5284
|
-
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}`;
|
|
5658
|
+
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}`;
|
|
5285
5659
|
case "label":
|
|
5286
|
-
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}`;
|
|
5660
|
+
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
|
|
5287
5661
|
case "comment":
|
|
5288
|
-
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}`;
|
|
5662
|
+
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}`;
|
|
5289
5663
|
case "pull_request": {
|
|
5290
5664
|
const clauses = ruleClauses(t);
|
|
5291
5665
|
const action = `action[${(t.action ?? []).join(",")}]`;
|
|
5292
|
-
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}`;
|
|
5666
|
+
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
|
|
5293
5667
|
}
|
|
5294
5668
|
default:
|
|
5295
5669
|
return "(unknown trigger)";
|
|
@@ -5571,6 +5945,11 @@ function createDashboardDeps(paths) {
|
|
|
5571
5945
|
settings: readSettingsView({ settingsFile: paths.settingsFile }),
|
|
5572
5946
|
triggers: readTriggers({ triggersPath: paths.triggersPath }),
|
|
5573
5947
|
pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
|
|
5948
|
+
// The operator's staged third-party pi packages (REQ-GLOBAL-PI-OVERLAY), for the armed triggers'
|
|
5949
|
+
// trust model. Like the four reads above it is a plain file read whose fs access lives entirely in
|
|
5950
|
+
// read-model.mjs -- this module never touches the filesystem -- and it degrades to a safe empty
|
|
5951
|
+
// shape rather than throwing, so a broken overlay cannot take the whole snapshot down.
|
|
5952
|
+
stagedPackages: readStagedPackages({ globalPiDir: paths.globalPiDir }),
|
|
5574
5953
|
// ONLY the id off the active Job -- a Job's `.data` holds issue title/body/username (PII), so it
|
|
5575
5954
|
// never enters the snapshot (no-pii-in-logs, INT-RUN-HISTORY-FILE-CONTRACT).
|
|
5576
5955
|
activeJobId: activeList?.[0]?.id ?? null
|
|
@@ -5610,6 +5989,7 @@ function makeDashboard({
|
|
|
5610
5989
|
let view = "LIST";
|
|
5611
5990
|
let selected = 0;
|
|
5612
5991
|
let detailRun = null;
|
|
5992
|
+
let detailSandbox = null;
|
|
5613
5993
|
let detailTrigger = null;
|
|
5614
5994
|
let tailJobId = null;
|
|
5615
5995
|
let tail = null;
|
|
@@ -5668,7 +6048,9 @@ function makeDashboard({
|
|
|
5668
6048
|
tailJobId,
|
|
5669
6049
|
tail,
|
|
5670
6050
|
tailTop,
|
|
5671
|
-
tailAvailable: typeof deps?.tailLog === "function"
|
|
6051
|
+
tailAvailable: typeof deps?.tailLog === "function",
|
|
6052
|
+
detailSandbox,
|
|
6053
|
+
sandboxAvailable: typeof deps?.launchSandbox === "function"
|
|
5672
6054
|
}, styler);
|
|
5673
6055
|
},
|
|
5674
6056
|
invalidate() {
|
|
@@ -5677,7 +6059,24 @@ function makeDashboard({
|
|
|
5677
6059
|
if (view === "RUN_DETAIL") {
|
|
5678
6060
|
if (matchesKey(data, "escape")) {
|
|
5679
6061
|
view = "LIST";
|
|
6062
|
+
detailSandbox = null;
|
|
5680
6063
|
tui?.requestRender?.();
|
|
6064
|
+
return;
|
|
6065
|
+
}
|
|
6066
|
+
if (data === "b" || data === "B") {
|
|
6067
|
+
if (typeof deps?.launchSandbox !== "function" || !detailRun?.jobId) return;
|
|
6068
|
+
if (detailSandbox && detailSandbox.retained === false) return;
|
|
6069
|
+
void (async () => {
|
|
6070
|
+
tui?.stop?.();
|
|
6071
|
+
try {
|
|
6072
|
+
await deps.launchSandbox({ jobId: detailRun.jobId });
|
|
6073
|
+
} catch {
|
|
6074
|
+
} finally {
|
|
6075
|
+
tui?.start?.();
|
|
6076
|
+
tui?.requestRender?.(true);
|
|
6077
|
+
}
|
|
6078
|
+
})();
|
|
6079
|
+
return;
|
|
5681
6080
|
}
|
|
5682
6081
|
return;
|
|
5683
6082
|
}
|
|
@@ -5743,6 +6142,7 @@ function makeDashboard({
|
|
|
5743
6142
|
void refresh();
|
|
5744
6143
|
} else {
|
|
5745
6144
|
detailRun = row.record;
|
|
6145
|
+
detailSandbox = typeof deps?.sandboxInfo === "function" ? deps.sandboxInfo({ jobId: row.record?.jobId }) : null;
|
|
5746
6146
|
view = "RUN_DETAIL";
|
|
5747
6147
|
tui?.requestRender?.();
|
|
5748
6148
|
}
|
|
@@ -5803,7 +6203,7 @@ function budgetMeters(budget, settings2, width) {
|
|
|
5803
6203
|
return out;
|
|
5804
6204
|
}
|
|
5805
6205
|
function renderPanel(snapshot, width, state, styler) {
|
|
5806
|
-
const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable } = state;
|
|
6206
|
+
const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable, detailSandbox, sandboxAvailable } = state;
|
|
5807
6207
|
const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH2;
|
|
5808
6208
|
const inner = Math.trunc(width) - 4;
|
|
5809
6209
|
const title = "pi-dispatch";
|
|
@@ -5812,7 +6212,7 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
5812
6212
|
const detailTitle = `trigger \xB7 ${t?.type ?? "?"}`;
|
|
5813
6213
|
const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
|
|
5814
6214
|
const sched = cronSchedInfo(t, snapshot);
|
|
5815
|
-
const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched);
|
|
6215
|
+
const lines = renderTriggerDetail(t, framed ? dw - 4 : 24, styler, sched, snapshot?.stagedPackages);
|
|
5816
6216
|
if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", "e edit \xB7 x delete \xB7 esc back"];
|
|
5817
6217
|
const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: triggerDetailHints(dw - 4, styler) });
|
|
5818
6218
|
return centerBlock(boxed, Math.trunc(width), dw);
|
|
@@ -5821,9 +6221,10 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
5821
6221
|
const detailTitle = `run ${detailRun?.jobId ?? "-"}`;
|
|
5822
6222
|
const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
|
|
5823
6223
|
const allRuns = Array.isArray(snapshot?.runs) ? snapshot.runs : [];
|
|
5824
|
-
const
|
|
5825
|
-
|
|
5826
|
-
|
|
6224
|
+
const canOpen = Boolean(sandboxAvailable && detailSandbox?.retained);
|
|
6225
|
+
const lines = renderRunDetail(detailRun, framed ? dw - 4 : 24, styler, allRuns, detailSandbox);
|
|
6226
|
+
if (!framed) return [detailTitle, "", ...lines.map((l) => styler.stripAnsi(l)), "", canOpen ? "b sandbox \xB7 esc back" : "esc back"];
|
|
6227
|
+
const boxed = frame(styler, { title: detailTitle, width: dw, lines, footer: runDetailHints(dw - 4, styler, canOpen) });
|
|
5827
6228
|
return centerBlock(boxed, Math.trunc(width), dw);
|
|
5828
6229
|
}
|
|
5829
6230
|
if (view === "LIVE_TAIL") {
|
|
@@ -5984,7 +6385,10 @@ function triggerRow(t, sel, inner, styler) {
|
|
|
5984
6385
|
const cursor = sel ? styler.fg("accent", "\u203A") : " ";
|
|
5985
6386
|
const kind = t?.type ?? "?";
|
|
5986
6387
|
const badge = styler.cell(kind, KIND_WIDTH, { color: KIND_COLOR[kind] ?? "muted" });
|
|
5987
|
-
|
|
6388
|
+
const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
|
|
6389
|
+
const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
|
|
6390
|
+
const res = t?.resume === true ? " " + styler.fg("warning", "[resume]") : "";
|
|
6391
|
+
return fitLine(`${cursor} ${badge} ${matchColored(t, styler)} ${targetColored(t, styler)}${pkgs}${img}${res}`, inner, styler);
|
|
5988
6392
|
}
|
|
5989
6393
|
function matchColored(t, styler) {
|
|
5990
6394
|
switch (t?.type) {
|
|
@@ -6013,7 +6417,7 @@ function targetColored(t, styler) {
|
|
|
6013
6417
|
const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
|
|
6014
6418
|
return `${arrow} ${styler.fg("success", "local")} ${folderPart}${flow}`;
|
|
6015
6419
|
}
|
|
6016
|
-
return `${arrow} ${styler.fg("accent", "github")} ${flow}`;
|
|
6420
|
+
return `${arrow} ${styler.fg("accent", t?.forge ?? "github")} ${flow}`;
|
|
6017
6421
|
}
|
|
6018
6422
|
function pauseLines(pauseWindows, inner, styler) {
|
|
6019
6423
|
const lines = [];
|
|
@@ -6084,7 +6488,7 @@ function settingsLines(settings2, inner, styler) {
|
|
|
6084
6488
|
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
6489
|
return [fitLine(l1, inner, styler), fitLine(l2, inner, styler)];
|
|
6086
6490
|
}
|
|
6087
|
-
function renderTriggerDetail(t, inner, styler, sched = null) {
|
|
6491
|
+
function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
6088
6492
|
if (!t) return [styler.cell("(no trigger)", inner, { color: "dim" })];
|
|
6089
6493
|
const out = [];
|
|
6090
6494
|
const kv = (k, v, color = "text") => fitLine(styler.cell(k, 12, { color: "muted" }) + " " + styler.fg(color, v), inner, styler);
|
|
@@ -6122,38 +6526,72 @@ function renderTriggerDetail(t, inner, styler, sched = null) {
|
|
|
6122
6526
|
out.push(kv("folder", `${t.folder ?? "-"}`, "success"));
|
|
6123
6527
|
out.push(kv("model", t.model ?? "deployment default", t.model ? "accent" : "dim"));
|
|
6124
6528
|
} else {
|
|
6125
|
-
out.push(kv("job", "
|
|
6126
|
-
out.push(kv("target",
|
|
6529
|
+
out.push(kv("job", t.forge ?? "-", "accent"));
|
|
6530
|
+
out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
|
|
6127
6531
|
out.push(kv("model", "deployment default", "dim"));
|
|
6128
6532
|
}
|
|
6533
|
+
out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
|
|
6534
|
+
out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
|
|
6129
6535
|
blank();
|
|
6130
6536
|
section("trust model");
|
|
6131
6537
|
for (const line of trustModel(t)) out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("text", line), inner, styler));
|
|
6538
|
+
if (t.packages === true) {
|
|
6539
|
+
const loads = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
|
|
6540
|
+
loads(`packages loaded \xB7 ${stagedNames(staged)}`);
|
|
6541
|
+
loads("third-party code on adversarial input, open network egress");
|
|
6542
|
+
}
|
|
6543
|
+
if (t.resume === true) {
|
|
6544
|
+
const keeps = (text) => out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("warning", text), inner, styler));
|
|
6545
|
+
keeps("persists the agent transcript to PI_SESSIONS_DIR");
|
|
6546
|
+
keeps("issue text, file contents, tool output, the agent's own reasoning");
|
|
6547
|
+
keeps("replayed into the next job on the same branch; forks never resume");
|
|
6548
|
+
}
|
|
6132
6549
|
return out;
|
|
6133
6550
|
}
|
|
6551
|
+
function stagedNames(staged) {
|
|
6552
|
+
const list = Array.isArray(staged?.packages) ? staged.packages : [];
|
|
6553
|
+
return list.length > 0 ? list.join(" \xB7 ") : "(none staged in the overlay)";
|
|
6554
|
+
}
|
|
6555
|
+
function forgeTargetLabel(forge) {
|
|
6556
|
+
if (forge === "gitlab") return "the triggering project#issue / !MR";
|
|
6557
|
+
if (forge === "azure") return "the triggering project/repo#work-item / !PR";
|
|
6558
|
+
return "the triggering repo#issue / PR";
|
|
6559
|
+
}
|
|
6134
6560
|
function trustModel(t) {
|
|
6135
|
-
|
|
6136
|
-
|
|
6561
|
+
if (t?.type === "cron") {
|
|
6562
|
+
return [
|
|
6563
|
+
"authorized by the operator's triggers file, at boot",
|
|
6564
|
+
"dedup by time \u2014 deterministic repeat:<id>:<millis> id",
|
|
6565
|
+
"lives in the worker \xB7 task is operator-authored"
|
|
6566
|
+
];
|
|
6567
|
+
}
|
|
6568
|
+
const subject = t?.type === "comment" ? "adversarial comment text" : "adversarial issue/PR text";
|
|
6569
|
+
const where = `lives in the receiver \xB7 task is ${subject}`;
|
|
6570
|
+
switch (t?.forge) {
|
|
6571
|
+
case "gitlab":
|
|
6137
6572
|
return [
|
|
6138
|
-
"authorized by the
|
|
6139
|
-
"
|
|
6140
|
-
|
|
6573
|
+
"authorized by the actor's resolved project access level (>= Developer) \u2014 a label is NOT approval here",
|
|
6574
|
+
"HMAC over the body (19.0+) or a bare X-Gitlab-Token \xB7 dedup by webhook-id",
|
|
6575
|
+
where
|
|
6141
6576
|
];
|
|
6142
|
-
case "
|
|
6143
|
-
case "pull_request":
|
|
6577
|
+
case "forgejo":
|
|
6144
6578
|
return [
|
|
6145
|
-
"authorized by
|
|
6146
|
-
"dedup by X-GitHub-Delivery GUID
|
|
6147
|
-
|
|
6579
|
+
"authorized by the actor's resolved repository permission (admin|write), on every trigger type",
|
|
6580
|
+
"HMAC over the body (GitHub-compatible) \xB7 dedup by X-GitHub-Delivery GUID",
|
|
6581
|
+
where
|
|
6148
6582
|
];
|
|
6149
|
-
case "
|
|
6583
|
+
case "azure":
|
|
6150
6584
|
return [
|
|
6151
|
-
"authorized by
|
|
6152
|
-
"dedup by
|
|
6153
|
-
|
|
6585
|
+
"authorized by the actor's resolved project membership \u2014 work items name the actor only by email",
|
|
6586
|
+
"NO HMAC: a shared secret that covers no bytes \xB7 dedup by the payload's own id (OQ-015)",
|
|
6587
|
+
where
|
|
6154
6588
|
];
|
|
6155
6589
|
default:
|
|
6156
|
-
return [
|
|
6590
|
+
return [
|
|
6591
|
+
t?.type === "comment" ? "authorized by a collaborator comment (author_association) + HMAC" : "authorized by a collaborator's label + HMAC webhook + author gate",
|
|
6592
|
+
"dedup by X-GitHub-Delivery GUID (redelivery-safe)",
|
|
6593
|
+
where
|
|
6594
|
+
];
|
|
6157
6595
|
}
|
|
6158
6596
|
}
|
|
6159
6597
|
function triggerDetailHints(inner, styler) {
|
|
@@ -6225,7 +6663,7 @@ function renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tai
|
|
|
6225
6663
|
if (!framed) return [boxTitle, "", ...lines, "", footer];
|
|
6226
6664
|
return box({ title: boxTitle, footer, width, sections: [{ lines }] });
|
|
6227
6665
|
}
|
|
6228
|
-
function renderRunDetail(record, inner, styler, allRuns = []) {
|
|
6666
|
+
function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
|
|
6229
6667
|
const r = record ?? {};
|
|
6230
6668
|
const show = (v) => v === null || v === void 0 ? "-" : String(v);
|
|
6231
6669
|
const out = [];
|
|
@@ -6248,12 +6686,20 @@ function renderRunDetail(record, inner, styler, allRuns = []) {
|
|
|
6248
6686
|
out.push(kv("turns", turnBits.join(" \xB7 ")));
|
|
6249
6687
|
const cost = typeof r.tokens?.cost === "number" ? ` \xB7 $${r.tokens.cost.toFixed(4)}` : "";
|
|
6250
6688
|
out.push(kv("tokens", `${show(r.tokens?.total)}${cost}`));
|
|
6689
|
+
const otherTotal = r.tokens?.otherTotal;
|
|
6690
|
+
if (typeof otherTotal === "number" && Number.isFinite(otherTotal) && otherTotal > 0) {
|
|
6691
|
+
out.push(kv("of which", `subagents: ${otherTotal}`, "dim"));
|
|
6692
|
+
}
|
|
6251
6693
|
const children = (Array.isArray(allRuns) ? allRuns : []).filter((x) => x?.parentJobId && r.jobId && x.parentJobId === r.jobId);
|
|
6252
6694
|
const chainBits = [r.parentJobId ? `child of ${r.parentJobId}` : "root"];
|
|
6253
6695
|
if (r.chainDepth !== null && r.chainDepth !== void 0) chainBits.push(`depth ${r.chainDepth}`);
|
|
6254
6696
|
if (children.length > 0) chainBits.push(`spawned ${children.length} \u2192 ${children.map((c) => c.jobId).join(", ")}`);
|
|
6255
6697
|
if (r.chainRefused) chainBits.push(`${r.chainRefused} refused`);
|
|
6256
6698
|
out.push(kv("chain", chainBits.join(" \xB7 ")));
|
|
6699
|
+
if (sandbox) {
|
|
6700
|
+
const state = sandbox.retained ? `${sandbox.running ? "running" : "retained"}${sandbox.expiresIn ? ` \xB7 ${sandbox.expiresIn} left` : ""}` : sandbox.reason ?? "swept";
|
|
6701
|
+
out.push(kv("sandbox", state, sandbox.retained ? "success" : "dim"));
|
|
6702
|
+
}
|
|
6257
6703
|
out.push(styler.cell("", inner));
|
|
6258
6704
|
out.push(styler.divider("post-mortem", null, inner));
|
|
6259
6705
|
out.push(fitLine(styler.fg("dim", "container torn down at job end \xB7 stored PII-free fields + optional raw-log overlay only"), inner, styler));
|
|
@@ -6291,8 +6737,10 @@ function formatDuration(ms) {
|
|
|
6291
6737
|
const h = Math.floor(m / 60);
|
|
6292
6738
|
return `${h}h ${m % 60}m`;
|
|
6293
6739
|
}
|
|
6294
|
-
function runDetailHints(inner, styler) {
|
|
6295
|
-
|
|
6740
|
+
function runDetailHints(inner, styler, canOpen = false) {
|
|
6741
|
+
const k = (key, label) => styler.fg("accent", key) + " " + styler.fg("dim", label);
|
|
6742
|
+
const bits = canOpen ? [k("b", "sandbox"), k("esc", "back")] : [k("esc", "back")];
|
|
6743
|
+
return fitLine(bits.join(styler.fg("dim", " \xB7 ")), inner, styler);
|
|
6296
6744
|
}
|
|
6297
6745
|
|
|
6298
6746
|
// src/index.ts
|
|
@@ -6465,11 +6913,12 @@ function registerTools(pi) {
|
|
|
6465
6913
|
pi.registerTool({
|
|
6466
6914
|
name: "dispatch_trigger_add",
|
|
6467
6915
|
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.
|
|
6916
|
+
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
6917
|
executionMode: "sequential",
|
|
6470
6918
|
parameters: typebox_exports.Object({
|
|
6471
6919
|
kind: typebox_exports.String(),
|
|
6472
6920
|
flow: typebox_exports.String(),
|
|
6921
|
+
forge: typebox_exports.Optional(typebox_exports.String()),
|
|
6473
6922
|
id: typebox_exports.Optional(typebox_exports.String()),
|
|
6474
6923
|
pattern: typebox_exports.Optional(typebox_exports.String()),
|
|
6475
6924
|
folder: typebox_exports.Optional(typebox_exports.String()),
|
|
@@ -6674,6 +7123,13 @@ function triggerList(paths) {
|
|
|
6674
7123
|
const t = readTriggers({ triggersPath: paths.triggersPath });
|
|
6675
7124
|
return Array.isArray(t?.triggers) ? t.triggers : [];
|
|
6676
7125
|
}
|
|
7126
|
+
var FORGE_PROMPT = "forge \u2014 github, gitlab, forgejo or azure";
|
|
7127
|
+
var PR_ACTION_VOCAB = {
|
|
7128
|
+
github: { hint: "labeled opened synchronize reopened", dflt: "labeled" },
|
|
7129
|
+
gitlab: { hint: "open update reopen approved", dflt: "update" },
|
|
7130
|
+
forgejo: { hint: "label_updated opened synchronized reopened", dflt: "label_updated" },
|
|
7131
|
+
azure: { hint: "created updated", dflt: "updated" }
|
|
7132
|
+
};
|
|
6677
7133
|
function buildTriggerEntry(kind, f) {
|
|
6678
7134
|
if (kind === "cron") {
|
|
6679
7135
|
const run = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
|
|
@@ -6685,13 +7141,16 @@ function buildTriggerEntry(kind, f) {
|
|
|
6685
7141
|
if (maxTurns !== void 0) run.maxTurns = maxTurns;
|
|
6686
7142
|
return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
|
|
6687
7143
|
}
|
|
6688
|
-
|
|
6689
|
-
|
|
7144
|
+
const forge = optStr(f.forge) ?? "github";
|
|
7145
|
+
const repository = optStr(f.repository);
|
|
7146
|
+
const forgeRun = (rest) => ({ kind: forge, ...rest, ...repository ? { repository } : {} });
|
|
7147
|
+
if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: forgeRun({ flow: f.flow }) };
|
|
7148
|
+
if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: forgeRun({ flow: f.flow }) };
|
|
6690
7149
|
if (kind === "pull_request") {
|
|
6691
7150
|
const on = { type: "pull_request", action: asWords(f.action) };
|
|
6692
7151
|
const any = asWords(f.labels ?? f.any);
|
|
6693
7152
|
if (any.length > 0) on.any = any;
|
|
6694
|
-
return { on, run: { kind:
|
|
7153
|
+
return { on, run: { kind: forge, flow: f.flow } };
|
|
6695
7154
|
}
|
|
6696
7155
|
return null;
|
|
6697
7156
|
}
|
|
@@ -6825,7 +7284,13 @@ async function openDashboard(paths, ctx, notify) {
|
|
|
6825
7284
|
deps: {
|
|
6826
7285
|
...createDashboardDeps(paths),
|
|
6827
7286
|
// log read stays here; overlay-only, returns readLogTail's result verbatim
|
|
6828
|
-
tailLog: ({ jobId, lines }) => readLogTail({ logsDir: paths.logsDir, jobId, lines })
|
|
7287
|
+
tailLog: ({ jobId, lines }) => readLogTail({ logsDir: paths.logsDir, jobId, lines }),
|
|
7288
|
+
// REQ-RESURRECTABLE-SANDBOX, both seams. The fs read and the spawn live HERE for the same reason
|
|
7289
|
+
// tailLog does: dashboard.ts renders and holds the tui, and touches no filesystem and no child
|
|
7290
|
+
// process of its own. It brackets the launch with tui.stop()/tui.start(); this side only builds
|
|
7291
|
+
// the argv and runs it attached to the terminal.
|
|
7292
|
+
sandboxInfo: ({ jobId }) => readSandboxInfo(paths, jobId),
|
|
7293
|
+
launchSandbox: ({ jobId }) => openSandboxSession(paths, jobId)
|
|
6829
7294
|
}
|
|
6830
7295
|
});
|
|
6831
7296
|
const opts = { overlay: true, overlayOptions: { width: "75%", maxHeight: "90%", anchor: "center" } };
|
|
@@ -6835,6 +7300,54 @@ async function openDashboard(paths, ctx, notify) {
|
|
|
6835
7300
|
await handleDashboardAction(result, paths, ctx);
|
|
6836
7301
|
}
|
|
6837
7302
|
}
|
|
7303
|
+
function readSandboxInfo(paths, jobId) {
|
|
7304
|
+
if (!jobId) return null;
|
|
7305
|
+
if (!paths?.sandboxRetentionHours) return { retained: false, reason: "retention off" };
|
|
7306
|
+
const manifest = readManifest({ sandboxDir: paths.sandboxDir, jobId });
|
|
7307
|
+
if (!manifest) return { retained: false, reason: "swept" };
|
|
7308
|
+
const keepUntil = Date.parse(manifest.keepUntil ?? "");
|
|
7309
|
+
const createdAt = Date.parse(manifest.createdAt ?? "");
|
|
7310
|
+
const until = Number.isFinite(keepUntil) ? keepUntil : createdAt + paths.sandboxRetentionHours * 36e5;
|
|
7311
|
+
if (!Number.isFinite(until)) return { retained: true };
|
|
7312
|
+
const hours = Math.max(0, Math.round((until - Date.now()) / 36e5));
|
|
7313
|
+
return { retained: true, pinned: Number.isFinite(keepUntil), expiresIn: hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d` };
|
|
7314
|
+
}
|
|
7315
|
+
async function openSandboxSession(paths, jobId) {
|
|
7316
|
+
const resolved = resolveSandbox({
|
|
7317
|
+
jobId,
|
|
7318
|
+
sandboxDir: paths.sandboxDir,
|
|
7319
|
+
retentionHours: paths.sandboxRetentionHours
|
|
7320
|
+
});
|
|
7321
|
+
if (resolved.refused) {
|
|
7322
|
+
process.stdout.write(`
|
|
7323
|
+
cannot open a sandbox for ${jobId}: ${resolved.message}
|
|
7324
|
+
`);
|
|
7325
|
+
await pauseForMessage();
|
|
7326
|
+
return;
|
|
7327
|
+
}
|
|
7328
|
+
const args = buildSandboxRunArgs({
|
|
7329
|
+
image: resolved.manifest.image,
|
|
7330
|
+
name: resolved.name,
|
|
7331
|
+
workspace: resolved.manifest.workspace,
|
|
7332
|
+
jobDir: resolved.manifest.dir,
|
|
7333
|
+
term: process.env.TERM,
|
|
7334
|
+
idleSeconds: (paths.sandboxIdleMinutes ?? 0) * 60
|
|
7335
|
+
});
|
|
7336
|
+
process.stdout.write(`
|
|
7337
|
+
opening ${sandboxContainerName(jobId)} \u2014 image ${resolved.manifest.image}
|
|
7338
|
+
`);
|
|
7339
|
+
process.stdout.write("no credentials are set in this container. exit the shell to return to the panel.\n\n");
|
|
7340
|
+
const { error } = await launchSandbox({ args });
|
|
7341
|
+
if (error) {
|
|
7342
|
+
process.stdout.write(`
|
|
7343
|
+
could not start docker: ${error.message}
|
|
7344
|
+
`);
|
|
7345
|
+
await pauseForMessage();
|
|
7346
|
+
}
|
|
7347
|
+
}
|
|
7348
|
+
function pauseForMessage() {
|
|
7349
|
+
return new Promise((resolve) => setTimeout(resolve, 2500));
|
|
7350
|
+
}
|
|
6838
7351
|
function splitWords(s) {
|
|
6839
7352
|
return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
|
6840
7353
|
}
|
|
@@ -6965,25 +7478,36 @@ async function addTriggerViaDialogs(paths, ui, notify) {
|
|
|
6965
7478
|
if (maxTurns === void 0) return;
|
|
6966
7479
|
entry = buildTriggerEntry("cron", { id, pattern, folder, flow, task, model, provider, maxTurns });
|
|
6967
7480
|
} else if (kind === "label") {
|
|
6968
|
-
const
|
|
7481
|
+
const forge = await ui.input(FORGE_PROMPT, "github");
|
|
7482
|
+
if (forge === void 0) return;
|
|
7483
|
+
const labels = await ui.input("labels \u2014 space-separated, any-of (a project member applies one to fire)", "pi:fix");
|
|
6969
7484
|
if (labels === void 0) return;
|
|
6970
7485
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the issue text is the task)", "fix");
|
|
6971
7486
|
if (flow === void 0) return;
|
|
6972
|
-
|
|
7487
|
+
const repository = forge === "azure" ? await ui.input("repository \u2014 the repo within the project to clone (azure work items name none)", "") : void 0;
|
|
7488
|
+
if (repository === void 0 && forge === "azure") return;
|
|
7489
|
+
entry = buildTriggerEntry("label", { labels, flow, forge, repository });
|
|
6973
7490
|
} else if (kind === "comment") {
|
|
7491
|
+
const forge = await ui.input(FORGE_PROMPT, "github");
|
|
7492
|
+
if (forge === void 0) return;
|
|
6974
7493
|
const phrase = await ui.input("trigger phrase \u2014 a comment containing this fires the flow (e.g. @pi)", "@pi");
|
|
6975
7494
|
if (phrase === void 0) return;
|
|
6976
7495
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the comment/issue text is the task)", "fix");
|
|
6977
7496
|
if (flow === void 0) return;
|
|
6978
|
-
|
|
7497
|
+
const repository = forge === "azure" ? await ui.input("repository \u2014 the repo within the project to clone (azure work items name none)", "") : void 0;
|
|
7498
|
+
if (repository === void 0 && forge === "azure") return;
|
|
7499
|
+
entry = buildTriggerEntry("comment", { phrase, flow, forge, repository });
|
|
6979
7500
|
} else {
|
|
6980
|
-
const
|
|
7501
|
+
const forge = await ui.input(FORGE_PROMPT, "github");
|
|
7502
|
+
if (forge === void 0) return;
|
|
7503
|
+
const vocab = PR_ACTION_VOCAB[forge] ?? PR_ACTION_VOCAB.github;
|
|
7504
|
+
const action = await ui.input(`MR/PR actions \u2014 space-separated: ${vocab.hint}`, vocab.dflt);
|
|
6981
7505
|
if (action === void 0) return;
|
|
6982
|
-
const labels = await ui.input("labels for 'labeled' \u2014 space-separated (blank for the auto actions)", "pi:review");
|
|
7506
|
+
const labels = forge === "azure" ? "" : await ui.input("labels for 'labeled' \u2014 space-separated (blank for the auto actions)", "pi:review");
|
|
6983
7507
|
if (labels === void 0) return;
|
|
6984
7508
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the PR text is the task)", "review");
|
|
6985
7509
|
if (flow === void 0) return;
|
|
6986
|
-
entry = buildTriggerEntry("pull_request", { action, labels, flow });
|
|
7510
|
+
entry = buildTriggerEntry("pull_request", { action, labels, flow, forge });
|
|
6987
7511
|
}
|
|
6988
7512
|
const res = writeTriggers({ triggersPath: paths.triggersPath, mutate: (list) => [...list, entry] });
|
|
6989
7513
|
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.5",
|
|
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, GitLab, Forgejo and Azure DevOps triggers (cron/label/comment/PR/MR/work item), 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,11 @@
|
|
|
11
11
|
"ai-agent",
|
|
12
12
|
"autonomous-agents",
|
|
13
13
|
"self-hosted",
|
|
14
|
+
"github",
|
|
15
|
+
"gitlab",
|
|
16
|
+
"forgejo",
|
|
17
|
+
"gitea",
|
|
18
|
+
"azure-devops",
|
|
14
19
|
"job-queue",
|
|
15
20
|
"cron",
|
|
16
21
|
"tui",
|
|
@@ -37,7 +42,7 @@
|
|
|
37
42
|
"skills": [
|
|
38
43
|
"./skills"
|
|
39
44
|
],
|
|
40
|
-
"image": "https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png"
|
|
45
|
+
"image": "https://raw.githubusercontent.com/edgehero/pi-dispatch/main/docs/images/banner.png?v=0.3.0"
|
|
41
46
|
},
|
|
42
47
|
"scripts": {
|
|
43
48
|
"build": "node build.mjs",
|
|
@@ -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.
|