@edgehero/pi-dispatch-admin 0.1.4 → 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 +2 -2
- package/dist/index.mjs +439 -63
- package/package.json +6 -3
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
|
|
@@ -40,7 +40,7 @@ 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.
|
package/dist/index.mjs
CHANGED
|
@@ -4223,12 +4223,89 @@ import * as nodeFs2 from "node:fs";
|
|
|
4223
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,11 +4448,19 @@ 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 RUN_KINDS = /* @__PURE__ */ new Set(["local", "github", "gitlab"]);
|
|
4375
|
-
var FORGE_KINDS = /* @__PURE__ */ new Set(["github", "gitlab"]);
|
|
4376
4451
|
var PR_ACTIONS = {
|
|
4377
4452
|
github: /* @__PURE__ */ new Set(["labeled", "opened", "synchronize", "reopened"]),
|
|
4378
|
-
gitlab: /* @__PURE__ */ new Set(["open", "update", "reopen", "approved"])
|
|
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"])
|
|
4379
4464
|
};
|
|
4380
4465
|
var ID_CHARSET = /^[A-Za-z0-9._-]+$/;
|
|
4381
4466
|
function isNonEmptyString2(value) {
|
|
@@ -4410,8 +4495,8 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
4410
4495
|
if (!ON_TYPES.has(on.type)) {
|
|
4411
4496
|
throw configError(`${at}: on.type must be one of cron|label|comment|pull_request (got ${JSON.stringify(on.type)}): ${path}`);
|
|
4412
4497
|
}
|
|
4413
|
-
if (!RUN_KINDS.
|
|
4414
|
-
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}`);
|
|
4415
4500
|
}
|
|
4416
4501
|
if (on.type === "cron") {
|
|
4417
4502
|
if (run.kind !== "local") {
|
|
@@ -4419,8 +4504,8 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
4419
4504
|
}
|
|
4420
4505
|
return normalizeCron(on, run, index, path, state);
|
|
4421
4506
|
}
|
|
4422
|
-
if (!
|
|
4423
|
-
throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of
|
|
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}`);
|
|
4424
4509
|
}
|
|
4425
4510
|
if (on.type === "label") return normalizeLabel(on, run, index, path);
|
|
4426
4511
|
if (on.type === "comment") return normalizeComment(on, run, index, path, state);
|
|
@@ -4464,9 +4549,10 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
4464
4549
|
}
|
|
4465
4550
|
const packages = validatePackagesFlag(run, `cron trigger "${id}"`, path);
|
|
4466
4551
|
const image = validateImageRef(run, `cron trigger "${id}"`, path);
|
|
4552
|
+
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
4467
4553
|
return {
|
|
4468
4554
|
on: { type: "cron", id, pattern },
|
|
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 }
|
|
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 }
|
|
4470
4556
|
};
|
|
4471
4557
|
}
|
|
4472
4558
|
function validatePackagesFlag(run, at, path) {
|
|
@@ -4475,6 +4561,12 @@ function validatePackagesFlag(run, at, path) {
|
|
|
4475
4561
|
}
|
|
4476
4562
|
return run.packages;
|
|
4477
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
|
+
}
|
|
4478
4570
|
function validateImageRef(run, at, path) {
|
|
4479
4571
|
const image = run.image;
|
|
4480
4572
|
if (image === void 0) return void 0;
|
|
@@ -4503,6 +4595,21 @@ function validatePredicate(on, index, path, requirePositive) {
|
|
|
4503
4595
|
}
|
|
4504
4596
|
return { any: on.any, all: on.all, none: on.none };
|
|
4505
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
|
+
}
|
|
4506
4613
|
function normalizeLabel(on, run, index, path) {
|
|
4507
4614
|
const at = `trigger at index ${index}`;
|
|
4508
4615
|
const predicate = validatePredicate(on, index, path, true);
|
|
@@ -4511,7 +4618,12 @@ function normalizeLabel(on, run, index, path) {
|
|
|
4511
4618
|
}
|
|
4512
4619
|
const packages = validatePackagesFlag(run, at, path);
|
|
4513
4620
|
const image = validateImageRef(run, at, path);
|
|
4514
|
-
|
|
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
|
+
};
|
|
4515
4627
|
}
|
|
4516
4628
|
function normalizeComment(on, run, index, path, state) {
|
|
4517
4629
|
const at = `trigger at index ${index}`;
|
|
@@ -4527,7 +4639,12 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
4527
4639
|
}
|
|
4528
4640
|
const packages = validatePackagesFlag(run, at, path);
|
|
4529
4641
|
const image = validateImageRef(run, at, path);
|
|
4530
|
-
|
|
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
|
+
};
|
|
4531
4648
|
}
|
|
4532
4649
|
function normalizePullRequest(on, run, index, path) {
|
|
4533
4650
|
const at = `trigger at index ${index}`;
|
|
@@ -4542,16 +4659,22 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
4542
4659
|
throw configError(`${at}: pull_request on.action has an unsupported ${run.kind} action ${JSON.stringify(a)} (expected ${expected}): ${path}`);
|
|
4543
4660
|
}
|
|
4544
4661
|
}
|
|
4545
|
-
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
|
+
}
|
|
4546
4667
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
4547
4668
|
if (!isNonEmptyString2(run.flow)) {
|
|
4548
4669
|
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string: ${path}`);
|
|
4549
4670
|
}
|
|
4550
4671
|
const packages = validatePackagesFlag(run, at, path);
|
|
4551
4672
|
const image = validateImageRef(run, at, path);
|
|
4673
|
+
const resume = validateResumeFlag(run, at, path);
|
|
4674
|
+
validateRepository(run, "pull_request", at, path);
|
|
4552
4675
|
return {
|
|
4553
4676
|
on: { type: "pull_request", action: [...actions], any: predicate.any, all: predicate.all, none: predicate.none },
|
|
4554
|
-
run: { kind: run.kind, flow: run.flow, packages, image }
|
|
4677
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume }
|
|
4555
4678
|
};
|
|
4556
4679
|
}
|
|
4557
4680
|
|
|
@@ -4793,9 +4916,9 @@ async function defaultGit(gitDir, args, { raw = false } = {}) {
|
|
|
4793
4916
|
|
|
4794
4917
|
// ../worker/src/git-dirty.mjs
|
|
4795
4918
|
import { execFileSync } from "node:child_process";
|
|
4796
|
-
function gitDirty(folder, { exec:
|
|
4919
|
+
function gitDirty(folder, { exec: exec3 = execFileSync } = {}) {
|
|
4797
4920
|
try {
|
|
4798
|
-
const out =
|
|
4921
|
+
const out = exec3("git", ["-C", folder, "status", "--porcelain"], { encoding: "utf8" });
|
|
4799
4922
|
return out.trim().length > 0;
|
|
4800
4923
|
} catch {
|
|
4801
4924
|
return null;
|
|
@@ -4814,6 +4937,60 @@ var execFileAsync = promisify2(execFile2);
|
|
|
4814
4937
|
|
|
4815
4938
|
// ../worker/src/docker-run.mjs
|
|
4816
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
|
+
}
|
|
4817
4994
|
|
|
4818
4995
|
// ../worker/src/packages.mjs
|
|
4819
4996
|
var PACKAGES_SUBDIR = "packages";
|
|
@@ -4857,6 +5034,13 @@ function resolvePaths(env = process.env) {
|
|
|
4857
5034
|
// normal deployment, in which no trigger can arm any package.
|
|
4858
5035
|
globalPiDir: env.PI_GLOBAL_PI_DIR || null,
|
|
4859
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),
|
|
4860
5044
|
// The two dispatch_run bounds the extension enforces producer-side, read DIRECTLY from env (never
|
|
4861
5045
|
// loadConfig, which throws on unrelated GitHub-auth problems). `delimitedList`/`nonNegativeInt` are
|
|
4862
5046
|
// private to the worker's config, so the same shapes are reimplemented here. Default roots [] fails
|
|
@@ -4904,9 +5088,9 @@ async function setQueuePaused({ url, paused, makeQueueFn = makeQueue, parseConne
|
|
|
4904
5088
|
}
|
|
4905
5089
|
}
|
|
4906
5090
|
var DISPATCH_RUN_TTL_SECONDS = 2 * 60 * 60;
|
|
4907
|
-
function revParseHead(folder, { exec:
|
|
5091
|
+
function revParseHead(folder, { exec: exec3 = execFileSync2 } = {}) {
|
|
4908
5092
|
try {
|
|
4909
|
-
const out =
|
|
5093
|
+
const out = exec3("git", ["-C", folder, "rev-parse", "HEAD"], { encoding: "utf8" });
|
|
4910
5094
|
const sha = out.trim();
|
|
4911
5095
|
return sha.length > 0 ? sha : null;
|
|
4912
5096
|
} catch {
|
|
@@ -5200,6 +5384,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5200
5384
|
const flow = typeof run.flow === "string" ? run.flow : null;
|
|
5201
5385
|
const packages = run.packages !== false;
|
|
5202
5386
|
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
5387
|
+
const resume = run.resume === true;
|
|
5203
5388
|
const forge = typeof run.kind === "string" && run.kind.trim() !== "" ? run.kind : null;
|
|
5204
5389
|
switch (on.type) {
|
|
5205
5390
|
case "cron":
|
|
@@ -5213,12 +5398,13 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5213
5398
|
// deployment default. Surfaced so the drill-in shows which schedules pin their own model.
|
|
5214
5399
|
model: typeof run.model === "string" ? run.model : null,
|
|
5215
5400
|
packages,
|
|
5216
|
-
image
|
|
5401
|
+
image,
|
|
5402
|
+
resume
|
|
5217
5403
|
};
|
|
5218
5404
|
case "label":
|
|
5219
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, forge };
|
|
5405
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, packages, image, resume, forge };
|
|
5220
5406
|
case "comment":
|
|
5221
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, forge };
|
|
5407
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, packages, image, resume, forge };
|
|
5222
5408
|
case "pull_request":
|
|
5223
5409
|
return {
|
|
5224
5410
|
type: "pull_request",
|
|
@@ -5229,6 +5415,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
5229
5415
|
flow,
|
|
5230
5416
|
packages,
|
|
5231
5417
|
image,
|
|
5418
|
+
resume,
|
|
5232
5419
|
forge
|
|
5233
5420
|
};
|
|
5234
5421
|
default:
|
|
@@ -5239,12 +5426,12 @@ function normalizeSelector(value) {
|
|
|
5239
5426
|
if (!Array.isArray(value)) return [];
|
|
5240
5427
|
return value.filter((member) => typeof member === "string");
|
|
5241
5428
|
}
|
|
5242
|
-
function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest = readStageManifest } = {}) {
|
|
5429
|
+
function readStagedPackages({ globalPiDir, fs = nodeFs2, readManifest: readManifest2 = readStageManifest } = {}) {
|
|
5243
5430
|
const empty = { stagedAt: null, packages: [] };
|
|
5244
5431
|
if (typeof globalPiDir !== "string" || globalPiDir === "") return empty;
|
|
5245
5432
|
let manifest;
|
|
5246
5433
|
try {
|
|
5247
|
-
manifest =
|
|
5434
|
+
manifest = readManifest2({
|
|
5248
5435
|
globalPiDir,
|
|
5249
5436
|
readFile: (path) => fs.readFileSync(path, "utf8"),
|
|
5250
5437
|
fileExists: (path) => fs.existsSync(path)
|
|
@@ -5300,6 +5487,75 @@ function clampLines(lines) {
|
|
|
5300
5487
|
return Math.min(2e3, Math.max(1, n));
|
|
5301
5488
|
}
|
|
5302
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
|
+
|
|
5303
5559
|
// src/render.mjs
|
|
5304
5560
|
var SETTINGS_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
|
|
5305
5561
|
var BUDGET_WINDOWS = [
|
|
@@ -5396,17 +5652,18 @@ function triggerLine(t) {
|
|
|
5396
5652
|
const forge = t?.forge && t.forge !== "github" ? ` [${t.forge}]` : "";
|
|
5397
5653
|
const pkgs = t?.packages === true ? " [packages]" : "";
|
|
5398
5654
|
const img = t?.image ? ` [image ${t.image}]` : "";
|
|
5655
|
+
const res = t?.resume === true ? " [resume]" : "";
|
|
5399
5656
|
switch (t?.type) {
|
|
5400
5657
|
case "cron":
|
|
5401
|
-
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}`;
|
|
5658
|
+
return `cron ${t.id ?? "-"} ${t.pattern ?? "-"} \u2192 ${t.folder ?? "-"}/${flow}${forge}${pkgs}${img}${res}`;
|
|
5402
5659
|
case "label":
|
|
5403
|
-
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}`;
|
|
5660
|
+
return `label ${ruleClauses(t) || "(no selector)"} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
|
|
5404
5661
|
case "comment":
|
|
5405
|
-
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}`;
|
|
5662
|
+
return `comment "${t.phrase ?? "-"}" \u2192 ${flow}${forge}${pkgs}${img}${res}`;
|
|
5406
5663
|
case "pull_request": {
|
|
5407
5664
|
const clauses = ruleClauses(t);
|
|
5408
5665
|
const action = `action[${(t.action ?? []).join(",")}]`;
|
|
5409
|
-
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}`;
|
|
5666
|
+
return `pull_request ${action}${clauses ? ` ${clauses}` : ""} \u2192 ${flow}${forge}${pkgs}${img}${res}`;
|
|
5410
5667
|
}
|
|
5411
5668
|
default:
|
|
5412
5669
|
return "(unknown trigger)";
|
|
@@ -5732,6 +5989,7 @@ function makeDashboard({
|
|
|
5732
5989
|
let view = "LIST";
|
|
5733
5990
|
let selected = 0;
|
|
5734
5991
|
let detailRun = null;
|
|
5992
|
+
let detailSandbox = null;
|
|
5735
5993
|
let detailTrigger = null;
|
|
5736
5994
|
let tailJobId = null;
|
|
5737
5995
|
let tail = null;
|
|
@@ -5790,7 +6048,9 @@ function makeDashboard({
|
|
|
5790
6048
|
tailJobId,
|
|
5791
6049
|
tail,
|
|
5792
6050
|
tailTop,
|
|
5793
|
-
tailAvailable: typeof deps?.tailLog === "function"
|
|
6051
|
+
tailAvailable: typeof deps?.tailLog === "function",
|
|
6052
|
+
detailSandbox,
|
|
6053
|
+
sandboxAvailable: typeof deps?.launchSandbox === "function"
|
|
5794
6054
|
}, styler);
|
|
5795
6055
|
},
|
|
5796
6056
|
invalidate() {
|
|
@@ -5799,7 +6059,24 @@ function makeDashboard({
|
|
|
5799
6059
|
if (view === "RUN_DETAIL") {
|
|
5800
6060
|
if (matchesKey(data, "escape")) {
|
|
5801
6061
|
view = "LIST";
|
|
6062
|
+
detailSandbox = null;
|
|
5802
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;
|
|
5803
6080
|
}
|
|
5804
6081
|
return;
|
|
5805
6082
|
}
|
|
@@ -5865,6 +6142,7 @@ function makeDashboard({
|
|
|
5865
6142
|
void refresh();
|
|
5866
6143
|
} else {
|
|
5867
6144
|
detailRun = row.record;
|
|
6145
|
+
detailSandbox = typeof deps?.sandboxInfo === "function" ? deps.sandboxInfo({ jobId: row.record?.jobId }) : null;
|
|
5868
6146
|
view = "RUN_DETAIL";
|
|
5869
6147
|
tui?.requestRender?.();
|
|
5870
6148
|
}
|
|
@@ -5925,7 +6203,7 @@ function budgetMeters(budget, settings2, width) {
|
|
|
5925
6203
|
return out;
|
|
5926
6204
|
}
|
|
5927
6205
|
function renderPanel(snapshot, width, state, styler) {
|
|
5928
|
-
const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable } = state;
|
|
6206
|
+
const { view, selected, detailRun, detailTrigger, tailJobId, tail, tailTop, tailAvailable, detailSandbox, sandboxAvailable } = state;
|
|
5929
6207
|
const framed = Number.isFinite(width) && Math.trunc(width) >= MIN_WIDTH2;
|
|
5930
6208
|
const inner = Math.trunc(width) - 4;
|
|
5931
6209
|
const title = "pi-dispatch";
|
|
@@ -5943,9 +6221,10 @@ function renderPanel(snapshot, width, state, styler) {
|
|
|
5943
6221
|
const detailTitle = `run ${detailRun?.jobId ?? "-"}`;
|
|
5944
6222
|
const dw = framed ? Math.min(Math.trunc(width), DRILL_WIDTH) : Math.trunc(width);
|
|
5945
6223
|
const allRuns = Array.isArray(snapshot?.runs) ? snapshot.runs : [];
|
|
5946
|
-
const
|
|
5947
|
-
|
|
5948
|
-
|
|
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) });
|
|
5949
6228
|
return centerBlock(boxed, Math.trunc(width), dw);
|
|
5950
6229
|
}
|
|
5951
6230
|
if (view === "LIVE_TAIL") {
|
|
@@ -6106,10 +6385,10 @@ function triggerRow(t, sel, inner, styler) {
|
|
|
6106
6385
|
const cursor = sel ? styler.fg("accent", "\u203A") : " ";
|
|
6107
6386
|
const kind = t?.type ?? "?";
|
|
6108
6387
|
const badge = styler.cell(kind, KIND_WIDTH, { color: KIND_COLOR[kind] ?? "muted" });
|
|
6109
|
-
const forge = t?.forge && t.forge !== "github" ? " " + styler.fg("accent", `[${t.forge}]`) : "";
|
|
6110
6388
|
const pkgs = t?.packages === true ? " " + styler.fg("warning", "[packages]") : "";
|
|
6111
6389
|
const img = t?.image ? " " + styler.fg("accent", `[${t.image}]`) : "";
|
|
6112
|
-
|
|
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);
|
|
6113
6392
|
}
|
|
6114
6393
|
function matchColored(t, styler) {
|
|
6115
6394
|
switch (t?.type) {
|
|
@@ -6138,7 +6417,7 @@ function targetColored(t, styler) {
|
|
|
6138
6417
|
const folderPart = base ? styler.fg("muted", base) + styler.fg("dim", "/") : "";
|
|
6139
6418
|
return `${arrow} ${styler.fg("success", "local")} ${folderPart}${flow}`;
|
|
6140
6419
|
}
|
|
6141
|
-
return `${arrow} ${styler.fg("accent", "github")} ${flow}`;
|
|
6420
|
+
return `${arrow} ${styler.fg("accent", t?.forge ?? "github")} ${flow}`;
|
|
6142
6421
|
}
|
|
6143
6422
|
function pauseLines(pauseWindows, inner, styler) {
|
|
6144
6423
|
const lines = [];
|
|
@@ -6248,10 +6527,11 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
6248
6527
|
out.push(kv("model", t.model ?? "deployment default", t.model ? "accent" : "dim"));
|
|
6249
6528
|
} else {
|
|
6250
6529
|
out.push(kv("job", t.forge ?? "-", "accent"));
|
|
6251
|
-
out.push(kv("target", t
|
|
6530
|
+
out.push(kv("target", forgeTargetLabel(t?.forge), "accent"));
|
|
6252
6531
|
out.push(kv("model", "deployment default", "dim"));
|
|
6253
6532
|
}
|
|
6254
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"));
|
|
6255
6535
|
blank();
|
|
6256
6536
|
section("trust model");
|
|
6257
6537
|
for (const line of trustModel(t)) out.push(fitLine(styler.fg("border", "\xB7 ") + styler.fg("text", line), inner, styler));
|
|
@@ -6260,35 +6540,58 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
6260
6540
|
loads(`packages loaded \xB7 ${stagedNames(staged)}`);
|
|
6261
6541
|
loads("third-party code on adversarial input, open network egress");
|
|
6262
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
|
+
}
|
|
6263
6549
|
return out;
|
|
6264
6550
|
}
|
|
6265
6551
|
function stagedNames(staged) {
|
|
6266
6552
|
const list = Array.isArray(staged?.packages) ? staged.packages : [];
|
|
6267
6553
|
return list.length > 0 ? list.join(" \xB7 ") : "(none staged in the overlay)";
|
|
6268
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
|
+
}
|
|
6269
6560
|
function trustModel(t) {
|
|
6270
|
-
|
|
6271
|
-
|
|
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":
|
|
6272
6572
|
return [
|
|
6273
|
-
"authorized by the
|
|
6274
|
-
"
|
|
6275
|
-
|
|
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
|
|
6276
6576
|
];
|
|
6277
|
-
case "
|
|
6278
|
-
case "pull_request":
|
|
6577
|
+
case "forgejo":
|
|
6279
6578
|
return [
|
|
6280
|
-
"authorized by
|
|
6281
|
-
"dedup by X-GitHub-Delivery GUID
|
|
6282
|
-
|
|
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
|
|
6283
6582
|
];
|
|
6284
|
-
case "
|
|
6583
|
+
case "azure":
|
|
6285
6584
|
return [
|
|
6286
|
-
"authorized by
|
|
6287
|
-
"dedup by
|
|
6288
|
-
|
|
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
|
|
6289
6588
|
];
|
|
6290
6589
|
default:
|
|
6291
|
-
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
|
+
];
|
|
6292
6595
|
}
|
|
6293
6596
|
}
|
|
6294
6597
|
function triggerDetailHints(inner, styler) {
|
|
@@ -6360,7 +6663,7 @@ function renderLiveTail({ snapshot, framed, width, tailJobId, tail, tailTop, tai
|
|
|
6360
6663
|
if (!framed) return [boxTitle, "", ...lines, "", footer];
|
|
6361
6664
|
return box({ title: boxTitle, footer, width, sections: [{ lines }] });
|
|
6362
6665
|
}
|
|
6363
|
-
function renderRunDetail(record, inner, styler, allRuns = []) {
|
|
6666
|
+
function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
|
|
6364
6667
|
const r = record ?? {};
|
|
6365
6668
|
const show = (v) => v === null || v === void 0 ? "-" : String(v);
|
|
6366
6669
|
const out = [];
|
|
@@ -6393,6 +6696,10 @@ function renderRunDetail(record, inner, styler, allRuns = []) {
|
|
|
6393
6696
|
if (children.length > 0) chainBits.push(`spawned ${children.length} \u2192 ${children.map((c) => c.jobId).join(", ")}`);
|
|
6394
6697
|
if (r.chainRefused) chainBits.push(`${r.chainRefused} refused`);
|
|
6395
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
|
+
}
|
|
6396
6703
|
out.push(styler.cell("", inner));
|
|
6397
6704
|
out.push(styler.divider("post-mortem", null, inner));
|
|
6398
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));
|
|
@@ -6430,8 +6737,10 @@ function formatDuration(ms) {
|
|
|
6430
6737
|
const h = Math.floor(m / 60);
|
|
6431
6738
|
return `${h}h ${m % 60}m`;
|
|
6432
6739
|
}
|
|
6433
|
-
function runDetailHints(inner, styler) {
|
|
6434
|
-
|
|
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);
|
|
6435
6744
|
}
|
|
6436
6745
|
|
|
6437
6746
|
// src/index.ts
|
|
@@ -6814,6 +7123,13 @@ function triggerList(paths) {
|
|
|
6814
7123
|
const t = readTriggers({ triggersPath: paths.triggersPath });
|
|
6815
7124
|
return Array.isArray(t?.triggers) ? t.triggers : [];
|
|
6816
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
|
+
};
|
|
6817
7133
|
function buildTriggerEntry(kind, f) {
|
|
6818
7134
|
if (kind === "cron") {
|
|
6819
7135
|
const run = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
|
|
@@ -6826,8 +7142,10 @@ function buildTriggerEntry(kind, f) {
|
|
|
6826
7142
|
return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
|
|
6827
7143
|
}
|
|
6828
7144
|
const forge = optStr(f.forge) ?? "github";
|
|
6829
|
-
|
|
6830
|
-
|
|
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 }) };
|
|
6831
7149
|
if (kind === "pull_request") {
|
|
6832
7150
|
const on = { type: "pull_request", action: asWords(f.action) };
|
|
6833
7151
|
const any = asWords(f.labels ?? f.any);
|
|
@@ -6966,7 +7284,13 @@ async function openDashboard(paths, ctx, notify) {
|
|
|
6966
7284
|
deps: {
|
|
6967
7285
|
...createDashboardDeps(paths),
|
|
6968
7286
|
// log read stays here; overlay-only, returns readLogTail's result verbatim
|
|
6969
|
-
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)
|
|
6970
7294
|
}
|
|
6971
7295
|
});
|
|
6972
7296
|
const opts = { overlay: true, overlayOptions: { width: "75%", maxHeight: "90%", anchor: "center" } };
|
|
@@ -6976,6 +7300,54 @@ async function openDashboard(paths, ctx, notify) {
|
|
|
6976
7300
|
await handleDashboardAction(result, paths, ctx);
|
|
6977
7301
|
}
|
|
6978
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
|
+
}
|
|
6979
7351
|
function splitWords(s) {
|
|
6980
7352
|
return String(s ?? "").split(/\s+/).map((w) => w.trim()).filter(Boolean);
|
|
6981
7353
|
}
|
|
@@ -7106,28 +7478,32 @@ async function addTriggerViaDialogs(paths, ui, notify) {
|
|
|
7106
7478
|
if (maxTurns === void 0) return;
|
|
7107
7479
|
entry = buildTriggerEntry("cron", { id, pattern, folder, flow, task, model, provider, maxTurns });
|
|
7108
7480
|
} else if (kind === "label") {
|
|
7109
|
-
const forge = await ui.input(
|
|
7481
|
+
const forge = await ui.input(FORGE_PROMPT, "github");
|
|
7110
7482
|
if (forge === void 0) return;
|
|
7111
7483
|
const labels = await ui.input("labels \u2014 space-separated, any-of (a project member applies one to fire)", "pi:fix");
|
|
7112
7484
|
if (labels === void 0) return;
|
|
7113
7485
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the issue text is the task)", "fix");
|
|
7114
7486
|
if (flow === void 0) return;
|
|
7115
|
-
|
|
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 });
|
|
7116
7490
|
} else if (kind === "comment") {
|
|
7117
|
-
const forge = await ui.input(
|
|
7491
|
+
const forge = await ui.input(FORGE_PROMPT, "github");
|
|
7118
7492
|
if (forge === void 0) return;
|
|
7119
7493
|
const phrase = await ui.input("trigger phrase \u2014 a comment containing this fires the flow (e.g. @pi)", "@pi");
|
|
7120
7494
|
if (phrase === void 0) return;
|
|
7121
7495
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the comment/issue text is the task)", "fix");
|
|
7122
7496
|
if (flow === void 0) return;
|
|
7123
|
-
|
|
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 });
|
|
7124
7500
|
} else {
|
|
7125
|
-
const forge = await ui.input(
|
|
7501
|
+
const forge = await ui.input(FORGE_PROMPT, "github");
|
|
7126
7502
|
if (forge === void 0) return;
|
|
7127
|
-
const
|
|
7128
|
-
const action = await ui.input(`MR/PR actions \u2014 space-separated: ${
|
|
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);
|
|
7129
7505
|
if (action === void 0) return;
|
|
7130
|
-
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");
|
|
7131
7507
|
if (labels === void 0) return;
|
|
7132
7508
|
const flow = await ui.input("flow \u2014 the .pi/skills/<name> skill to run (the PR text is the task)", "review");
|
|
7133
7509
|
if (flow === void 0) return;
|
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 GitHub and
|
|
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",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"self-hosted",
|
|
14
14
|
"github",
|
|
15
15
|
"gitlab",
|
|
16
|
+
"forgejo",
|
|
17
|
+
"gitea",
|
|
18
|
+
"azure-devops",
|
|
16
19
|
"job-queue",
|
|
17
20
|
"cron",
|
|
18
21
|
"tui",
|
|
@@ -39,7 +42,7 @@
|
|
|
39
42
|
"skills": [
|
|
40
43
|
"./skills"
|
|
41
44
|
],
|
|
42
|
-
"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"
|
|
43
46
|
},
|
|
44
47
|
"scripts": {
|
|
45
48
|
"build": "node build.mjs",
|