@edgehero/pi-dispatch-admin 1.8.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +458 -68
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -7598,6 +7598,214 @@ var init_build = __esm({
|
|
|
7598
7598
|
}
|
|
7599
7599
|
});
|
|
7600
7600
|
|
|
7601
|
+
// ../worker/src/backends.mjs
|
|
7602
|
+
function backendFor(name) {
|
|
7603
|
+
const key = name ?? DEFAULT_BACKEND;
|
|
7604
|
+
return Object.hasOwn(BACKENDS, key) ? BACKENDS[key] : void 0;
|
|
7605
|
+
}
|
|
7606
|
+
function parseBackendList(raw) {
|
|
7607
|
+
const names = (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
7608
|
+
if (names.length === 0) return [DEFAULT_BACKEND];
|
|
7609
|
+
for (const name of names) {
|
|
7610
|
+
if (!Object.hasOwn(BACKENDS, name)) {
|
|
7611
|
+
throw new Error(`PI_BACKENDS names an unknown backend ${JSON.stringify(name)} (known: ${BACKEND_NAMES.join(", ")})`);
|
|
7612
|
+
}
|
|
7613
|
+
}
|
|
7614
|
+
const unique = [...new Set(names)];
|
|
7615
|
+
if (!unique.includes(DEFAULT_BACKEND)) {
|
|
7616
|
+
throw new Error(`PI_BACKENDS must include ${JSON.stringify(DEFAULT_BACKEND)}: a trigger that names no backend is dispatched there, so a set without it leaves every unflagged trigger nowhere to run`);
|
|
7617
|
+
}
|
|
7618
|
+
return unique;
|
|
7619
|
+
}
|
|
7620
|
+
var DOCKER_NEVER_STARTED_EXITS, ENFORCED, ASSERTED, ABSENT, RANK, PROPERTIES_TABLE, PROPERTIES, PROPERTY_NAMES, BACKENDS_TABLE, BACKENDS, BACKEND_NAMES, DEFAULT_BACKEND;
|
|
7621
|
+
var init_backends = __esm({
|
|
7622
|
+
"../worker/src/backends.mjs"() {
|
|
7623
|
+
DOCKER_NEVER_STARTED_EXITS = Object.freeze([125, 126, 127]);
|
|
7624
|
+
ENFORCED = "enforced";
|
|
7625
|
+
ASSERTED = "asserted";
|
|
7626
|
+
ABSENT = "absent";
|
|
7627
|
+
RANK = { [ABSENT]: 0, [ASSERTED]: 1, [ENFORCED]: 2 };
|
|
7628
|
+
PROPERTIES_TABLE = {
|
|
7629
|
+
isolation: {
|
|
7630
|
+
question: "capabilities dropped, no-new-privileges, pid and memory bounds on the job container",
|
|
7631
|
+
armedBy: null
|
|
7632
|
+
},
|
|
7633
|
+
ephemeral: {
|
|
7634
|
+
// CONST-ISOLATION-CONTAINER-PER-JOB's core sentence, and NOT covered by `isolation`: a backend
|
|
7635
|
+
// reusing one long-lived container satisfies every flag above word for word and still leaks state
|
|
7636
|
+
// between mutually untrusting issue authors, which is the whole reason that constraint exists.
|
|
7637
|
+
question: "one container per job, destroyed after it, never reused across jobs",
|
|
7638
|
+
armedBy: null
|
|
7639
|
+
},
|
|
7640
|
+
mountSet: {
|
|
7641
|
+
// The Acceptance clause of CONST-ISOLATION-CONTAINER-PER-JOB: "the agent has no filesystem path to
|
|
7642
|
+
// the host outside the declared mounts", and it names PI_SESSIONS_DIR as never bind-mounted into any
|
|
7643
|
+
// container. Without this property a backend could declare everything else enforced and mount the
|
|
7644
|
+
// docker socket, which is not a weakened boundary but no boundary at all.
|
|
7645
|
+
question: "only the declared mounts exist; no docker socket, no home dir, no shared session store",
|
|
7646
|
+
armedBy: null
|
|
7647
|
+
},
|
|
7648
|
+
egress: {
|
|
7649
|
+
// ARMED, not unconditional, and the table would be re-proposing a rejected constitution entry if it
|
|
7650
|
+
// said otherwise. See the header.
|
|
7651
|
+
question: "the job reaches only what the allowlist proxy permits (CONST-EGRESS-POLICY-IN-THE-ARGV)",
|
|
7652
|
+
armedBy: "PI_EGRESS"
|
|
7653
|
+
},
|
|
7654
|
+
jobToJobIsolation: {
|
|
7655
|
+
// The measured half of REQ-EGRESS-ALLOWLIST. `egress` is about OUTBOUND reach; this is about whether
|
|
7656
|
+
// two jobs can reach each other, which a backend could fail while passing the egress question by
|
|
7657
|
+
// routing every job through the proxy on one shared segment. Also armed: with PI_EGRESS=0 both job
|
|
7658
|
+
// containers sit on docker's default bridge, where egress.mjs records they can reach each other by
|
|
7659
|
+
// IP today.
|
|
7660
|
+
question: "two jobs cannot reach each other, structurally rather than by policy",
|
|
7661
|
+
armedBy: "PI_EGRESS"
|
|
7662
|
+
},
|
|
7663
|
+
imagePinning: {
|
|
7664
|
+
question: "the image cannot be fetched at run time, so a typo'd name is unreachable (--pull=never)",
|
|
7665
|
+
armedBy: null
|
|
7666
|
+
},
|
|
7667
|
+
exitCodes: {
|
|
7668
|
+
// Narrowed deliberately. An earlier draft also claimed "un-interleaved stdout arrives undistorted",
|
|
7669
|
+
// which this project does not have: run-container.mjs tees two independently buffered pipes into one
|
|
7670
|
+
// sink, run-history.mjs reads only the last 8KB, and `OQ-003` records that anything sharing the
|
|
7671
|
+
// container's stdout can land a partial write inside a runner line. The integer is the part that is
|
|
7672
|
+
// actually guaranteed, and it is the part every retry decision rests on.
|
|
7673
|
+
question: "the container's integer exit code reaches the processor unmodified (INT-RUNNER-EXIT-CODE-PROTOCOL)",
|
|
7674
|
+
armedBy: null
|
|
7675
|
+
},
|
|
7676
|
+
abortable: {
|
|
7677
|
+
// REQ-JOB-TIMEOUT-30M. Declared two slices before the seam existed, on purpose: `stopContainer` was
|
|
7678
|
+
// hard-wired in index.mjs's abort path, so a second backend could have passed every other check with
|
|
7679
|
+
// no way to stop a runaway container and nothing in this table would have moved. A gap that is
|
|
7680
|
+
// declared is a refusal; a gap that is unnamed is a surprise. The seam landed in slice 4.
|
|
7681
|
+
question: "a running job can be stopped on the 30-minute timeout or a shutdown, and the abort is distinguishable from a crash",
|
|
7682
|
+
armedBy: null
|
|
7683
|
+
},
|
|
7684
|
+
readOnlyJobInputs: {
|
|
7685
|
+
question: "/job is read-only by the kernel, not by convention (INT-CONTAINER-JOB-INPUTS)",
|
|
7686
|
+
armedBy: null
|
|
7687
|
+
},
|
|
7688
|
+
nonRoot: {
|
|
7689
|
+
question: "the agent runs as a non-root user",
|
|
7690
|
+
armedBy: null
|
|
7691
|
+
},
|
|
7692
|
+
secretsCustody: {
|
|
7693
|
+
// Stated as what the code actually earns and a test can read back, rather than as "values stay on the
|
|
7694
|
+
// operator's host", which is a topological fact about `remote: false` and not something the worker
|
|
7695
|
+
// builds. The host-side argv exposure under a default `hidepid` is real, pre-existing, disclosed in
|
|
7696
|
+
// SECURITY.md, and deliberately outside this sentence rather than denied by it. WHETHER THE VALUES
|
|
7697
|
+
// CROSS A NETWORK is deliberately not asked here either -- that is `credentialTransit`, which answers
|
|
7698
|
+
// it honestly, and folding the two would let this one's verified half carry the other's unverified.
|
|
7699
|
+
question: "no resolved secret VALUE reaches a log, a run record or a forge comment",
|
|
7700
|
+
armedBy: null
|
|
7701
|
+
},
|
|
7702
|
+
credentialTransit: {
|
|
7703
|
+
// CONST-TOKEN-SCOPED-PER-JOB is what CONST-EGRESS-POLICY-IN-THE-ARGV calls "what actually bounds the
|
|
7704
|
+
// damage", and the first draft of this table had no property for it at all. A remote backend must
|
|
7705
|
+
// ship the provider key and the per-job forge token to a daemon it does not own; that is a different
|
|
7706
|
+
// question from secretsCustody, which is about this host's own logs and records.
|
|
7707
|
+
question: "the provider key and the per-job forge token reach the container without crossing a network this deployment does not own",
|
|
7708
|
+
armedBy: null
|
|
7709
|
+
},
|
|
7710
|
+
localFolders: {
|
|
7711
|
+
question: "a local folder can be bind-mounted and edited in place (DES-WORKER-ON-HOST finding 2)",
|
|
7712
|
+
armedBy: null
|
|
7713
|
+
}
|
|
7714
|
+
};
|
|
7715
|
+
PROPERTIES = Object.freeze(
|
|
7716
|
+
Object.fromEntries(Object.entries(PROPERTIES_TABLE).map(([name, entry]) => [name, Object.freeze({ ...entry })]))
|
|
7717
|
+
);
|
|
7718
|
+
PROPERTY_NAMES = Object.freeze(Object.keys(PROPERTIES));
|
|
7719
|
+
BACKENDS_TABLE = {
|
|
7720
|
+
local: {
|
|
7721
|
+
/**
|
|
7722
|
+
* The Docker daemon on the worker's own host -- what every deployment has always used, and what
|
|
7723
|
+
* `DES-WORKER-ON-HOST` chose deliberately: the worker runs on the host and shells out to the real
|
|
7724
|
+
* `docker` CLI, inheriting its cross-platform path translation rather than reimplementing it.
|
|
7725
|
+
*/
|
|
7726
|
+
describe: "the Docker daemon on this worker's host",
|
|
7727
|
+
remote: false,
|
|
7728
|
+
declares: {
|
|
7729
|
+
// Built by this worker's own argv, in `ISOLATION_FLAGS`, and asserted back member-by-member
|
|
7730
|
+
// against the imported array by two tests. `dockerArgsFromSpec` additionally refuses a
|
|
7731
|
+
// `dockerExtra` carrying a flag that would supersede one of them -- membership in an argv is not
|
|
7732
|
+
// effectiveness of that argv, and without that guard those two assertions would pass on an argv
|
|
7733
|
+
// with no boundary left. It is a deny-list, so it NARROWS that gap rather than closing it; what
|
|
7734
|
+
// closes it today is that the only production caller passes fixed literals.
|
|
7735
|
+
isolation: ENFORCED,
|
|
7736
|
+
// `--rm` leads ISOLATION_FLAGS and the container name carries the job id, so no container is
|
|
7737
|
+
// reachable to reuse even in principle.
|
|
7738
|
+
ephemeral: ENFORCED,
|
|
7739
|
+
// The mount list is built by `containerSpec` from a fixed set of named host paths and nothing
|
|
7740
|
+
// else. There is no pass-through, and the shared session store under PI_SESSIONS_DIR is never
|
|
7741
|
+
// among them -- only this job's own copy.
|
|
7742
|
+
mountSet: ENFORCED,
|
|
7743
|
+
// CAPABILITY, gated by PI_EGRESS. When armed: `--network=pi-job-<id>-net`, `--internal`, created
|
|
7744
|
+
// before the spawn and removed in a finally. When PI_EGRESS=0 the flag is absent and the job is
|
|
7745
|
+
// on docker's default bridge -- which is why this word is `armedBy` rather than flat.
|
|
7746
|
+
egress: ENFORCED,
|
|
7747
|
+
// Same switch, and the same reason: an `--internal` network holding exactly two endpoints is what
|
|
7748
|
+
// makes job-to-job structurally impossible. Unarmed, egress.mjs records the opposite is true.
|
|
7749
|
+
jobToJobIsolation: ENFORCED,
|
|
7750
|
+
// `--pull=never`, which makes the fetch branch unreachable rather than merely unlikely. Every
|
|
7751
|
+
// path that starts a container from the job image carries it, doctor's probes included.
|
|
7752
|
+
imagePinning: ENFORCED,
|
|
7753
|
+
// `spawn` on the CLI: the integer comes from the `close` event and is passed to `decideRetry`
|
|
7754
|
+
// unmodified.
|
|
7755
|
+
exitCodes: ENFORCED,
|
|
7756
|
+
// `docker stop` on the name, and the abort FLAG rather than the code is the discriminator -- a
|
|
7757
|
+
// worker SIGKILL and a kernel OOM both surface as 137. The worker also BOUNDS the wait after the
|
|
7758
|
+
// abort, so a stop that does not take frees the slot instead of holding it forever.
|
|
7759
|
+
abortable: ENFORCED,
|
|
7760
|
+
// `-v <host>:/job:ro` -- the kernel enforces it. `verify-image.sh` proves it with a live write
|
|
7761
|
+
// attempt rather than trusting the flag.
|
|
7762
|
+
readOnlyJobInputs: ENFORCED,
|
|
7763
|
+
// ASSERTED, not enforced, and this is the honest one. Non-root is `USER pi` in the IMAGE, not in
|
|
7764
|
+
// the worker's argv -- SECURITY.md says so in terms: "Non-root is not in that argv." An
|
|
7765
|
+
// operator-built image can run as root and nothing here would refuse it (`OQ-012`).
|
|
7766
|
+
nonRoot: ASSERTED,
|
|
7767
|
+
// Traced end to end: the resolver logs a stderr BYTE COUNT and never the bytes, the refusal
|
|
7768
|
+
// comment carries neither the reference nor the resolver's path nor a byte of what it printed,
|
|
7769
|
+
// and `buildRecord` is an explicit literal with no spread. Where the values GO is
|
|
7770
|
+
// `credentialTransit`'s question, not this one's.
|
|
7771
|
+
secretsCustody: ENFORCED,
|
|
7772
|
+
// ASSERTED, and this is the second honest one. The intent is that the daemon is on this host, so
|
|
7773
|
+
// nothing leaves it -- but every spawn is `docker` with the worker's own environment inherited,
|
|
7774
|
+
// and `DOCKER_HOST=tcp://...` or a `docker context` redirects that connection to another machine
|
|
7775
|
+
// with the provider key and the per-job forge token riding along as `-e NAME=VALUE`. `DOCKER_HOST`
|
|
7776
|
+
// appears NOWHERE in this repository: no code sets it, no check refuses it, no test reads it back.
|
|
7777
|
+
// By this file's own definition of `enforced` -- "this worker CAN build it, in its own code, and a
|
|
7778
|
+
// test in this repo reads it back" -- there is no such code, so the word would be exactly the
|
|
7779
|
+
// overclaim `nonRoot` avoids one property up. A boot check on DOCKER_HOST would earn `enforced`.
|
|
7780
|
+
credentialTransit: ASSERTED,
|
|
7781
|
+
// The whole reason `DES-WORKER-ON-HOST` reversed the containerised worker.
|
|
7782
|
+
localFolders: ENFORCED
|
|
7783
|
+
},
|
|
7784
|
+
/**
|
|
7785
|
+
* WHO is asserting each `asserted` property. Required for every property this backend declares
|
|
7786
|
+
* ASSERTED and meaningless for the others, which a test pins both ways.
|
|
7787
|
+
*
|
|
7788
|
+
* Exists because "asserted" alone is not actionable: it tells an operator the worker is not the one
|
|
7789
|
+
* providing the property without telling them who is, so they cannot go and check. `doctor` prints
|
|
7790
|
+
* this beside the word, which is what lets the claim "asserted names who is asserting it" be true
|
|
7791
|
+
* rather than aspirational. For a vendor adapter this is where "the vendor's documentation" goes.
|
|
7792
|
+
*/
|
|
7793
|
+
asserts: {
|
|
7794
|
+
nonRoot: "the job image's USER directive (this repo's builds `USER pi`; an operator-built image may not)",
|
|
7795
|
+
credentialTransit: "the docker endpoint DOCKER_HOST resolves to, which is this host unless something redirects it"
|
|
7796
|
+
}
|
|
7797
|
+
}
|
|
7798
|
+
};
|
|
7799
|
+
BACKENDS = Object.freeze(
|
|
7800
|
+
Object.fromEntries(
|
|
7801
|
+
Object.entries(BACKENDS_TABLE).map(([name, entry]) => [name, Object.freeze({ ...entry, declares: Object.freeze({ ...entry.declares }) })])
|
|
7802
|
+
)
|
|
7803
|
+
);
|
|
7804
|
+
BACKEND_NAMES = Object.freeze(Object.keys(BACKENDS));
|
|
7805
|
+
DEFAULT_BACKEND = "local";
|
|
7806
|
+
}
|
|
7807
|
+
});
|
|
7808
|
+
|
|
7601
7809
|
// ../worker/src/egress.mjs
|
|
7602
7810
|
var init_egress = __esm({
|
|
7603
7811
|
"../worker/src/egress.mjs"() {
|
|
@@ -7738,6 +7946,7 @@ function defaultSettingsFile() {
|
|
|
7738
7946
|
var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT, WORKER_ONLY_SECRET_VARS, EGRESS_ENV_VARS;
|
|
7739
7947
|
var init_config2 = __esm({
|
|
7740
7948
|
"../worker/src/config.mjs"() {
|
|
7949
|
+
init_backends();
|
|
7741
7950
|
init_egress();
|
|
7742
7951
|
init_forges();
|
|
7743
7952
|
init_secret_profiles();
|
|
@@ -8557,10 +8766,11 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
8557
8766
|
validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8558
8767
|
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
8559
8768
|
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
8769
|
+
const backend = validateBackend(on, run, `cron trigger "${id}"`, path, { localWorkspace: true });
|
|
8560
8770
|
validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
8561
8771
|
return {
|
|
8562
8772
|
on: { type: "cron", id, pattern },
|
|
8563
|
-
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, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile } }
|
|
8773
|
+
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, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...backend !== void 0 && { backend } }
|
|
8564
8774
|
};
|
|
8565
8775
|
}
|
|
8566
8776
|
function validatePackagesFlag(run, at, path) {
|
|
@@ -8807,6 +9017,45 @@ function validateSecretsProfile(run, at, path) {
|
|
|
8807
9017
|
}
|
|
8808
9018
|
return profile;
|
|
8809
9019
|
}
|
|
9020
|
+
function refusesLocalWorkspace(entry, localWorkspace) {
|
|
9021
|
+
return Boolean(localWorkspace) && entry?.remote !== false;
|
|
9022
|
+
}
|
|
9023
|
+
function validateBackend(on, run, at, path, { localWorkspace }) {
|
|
9024
|
+
for (const [label, source, exactIsLegal] of [
|
|
9025
|
+
["run", run, true],
|
|
9026
|
+
["on", on, false]
|
|
9027
|
+
]) {
|
|
9028
|
+
for (const key of Object.keys(source ?? {})) {
|
|
9029
|
+
if (exactIsLegal && key === "backend") continue;
|
|
9030
|
+
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
9031
|
+
const targets = ["backend", "backends"];
|
|
9032
|
+
const isSubsequence = (needle, hay) => {
|
|
9033
|
+
let i = 0;
|
|
9034
|
+
for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
|
|
9035
|
+
return i === needle.length;
|
|
9036
|
+
};
|
|
9037
|
+
const suspicious = /^[\x20-\x7E]*$/.test(key) ? targets.includes(normalized) : normalized.length >= 4 && targets.some((t) => isSubsequence(normalized, t));
|
|
9038
|
+
if (!suspicious) continue;
|
|
9039
|
+
throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.backend? A venue the loader drops runs the job somewhere else while the file reads as though it chose, so a near miss is refused rather than dropped: ${path}`);
|
|
9040
|
+
}
|
|
9041
|
+
}
|
|
9042
|
+
const backend = run.backend;
|
|
9043
|
+
if (backend === void 0) return void 0;
|
|
9044
|
+
if (!isNonEmptyString5(backend)) {
|
|
9045
|
+
throw configError(`${at}: run.backend must be a non-empty string naming one of the backends this deployment can run jobs in (got ${JSON.stringify(backend)}): ${path}`);
|
|
9046
|
+
}
|
|
9047
|
+
if (!ID_CHARSET.test(backend)) {
|
|
9048
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} may use letters, digits, dot, dash and underscore only -- PI_BACKENDS is a comma-separated list, so a name carrying that separator could not be blessed: ${path}`);
|
|
9049
|
+
}
|
|
9050
|
+
const entry = backendFor(backend);
|
|
9051
|
+
if (!entry) {
|
|
9052
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} is not a backend this build knows (known: ${BACKEND_NAMES.join(", ")}) -- whether your deployment BLESSES it is PI_BACKENDS, checked before the job spends: ${path}`);
|
|
9053
|
+
}
|
|
9054
|
+
if (refusesLocalWorkspace(entry, localWorkspace)) {
|
|
9055
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} is remote, and this trigger runs against a folder on THIS machine that has to be bind-mounted and edited in place -- there is no volume to hide behind (DES-WORKER-ON-HOST): ${path}`);
|
|
9056
|
+
}
|
|
9057
|
+
return backend;
|
|
9058
|
+
}
|
|
8810
9059
|
function validateWaitFor(on, run, at, path, { onType }) {
|
|
8811
9060
|
for (const [label, source, exactIsLegal] of [
|
|
8812
9061
|
["run", run, true],
|
|
@@ -8902,10 +9151,11 @@ function normalizeLabel(on, run, index, path) {
|
|
|
8902
9151
|
const replicas = validateReplicas(run, at, path);
|
|
8903
9152
|
const secrets = validateSecrets(run, at, path);
|
|
8904
9153
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9154
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
8905
9155
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8906
9156
|
return {
|
|
8907
9157
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
8908
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9158
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
8909
9159
|
};
|
|
8910
9160
|
}
|
|
8911
9161
|
function normalizeComment(on, run, index, path, state) {
|
|
@@ -8934,10 +9184,11 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
8934
9184
|
const replicas = validateReplicas(run, at, path);
|
|
8935
9185
|
const secrets = validateSecrets(run, at, path);
|
|
8936
9186
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9187
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
8937
9188
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8938
9189
|
return {
|
|
8939
9190
|
on: { type: "comment", phrase: on.phrase },
|
|
8940
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9191
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...repository !== void 0 && { repository }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
8941
9192
|
};
|
|
8942
9193
|
}
|
|
8943
9194
|
function normalizeIssue(on, run, index, path) {
|
|
@@ -8976,6 +9227,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8976
9227
|
const replicas = validateReplicas(run, at, path);
|
|
8977
9228
|
const secrets = validateSecrets(run, at, path);
|
|
8978
9229
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9230
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
8979
9231
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
8980
9232
|
return {
|
|
8981
9233
|
on: {
|
|
@@ -8986,7 +9238,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
8986
9238
|
...number !== void 0 && { number },
|
|
8987
9239
|
...once !== void 0 && { once }
|
|
8988
9240
|
},
|
|
8989
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9241
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
8990
9242
|
};
|
|
8991
9243
|
}
|
|
8992
9244
|
function normalizePullRequest(on, run, index, path) {
|
|
@@ -9034,6 +9286,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
9034
9286
|
const replicas = validateReplicas(run, at, path);
|
|
9035
9287
|
const secrets = validateSecrets(run, at, path);
|
|
9036
9288
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
9289
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
9037
9290
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
9038
9291
|
return {
|
|
9039
9292
|
on: {
|
|
@@ -9049,7 +9302,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
9049
9302
|
...number !== void 0 && { number },
|
|
9050
9303
|
...once !== void 0 && { once }
|
|
9051
9304
|
},
|
|
9052
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor } }
|
|
9305
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...command !== void 0 && { command }, ...skillsDir !== void 0 && { skillsDir }, ...instructions !== void 0 && { instructions }, ...secrets !== void 0 && { secrets }, ...secretsProfile !== void 0 && { secretsProfile }, ...waitFor !== void 0 && { waitFor }, ...backend !== void 0 && { backend } }
|
|
9053
9306
|
};
|
|
9054
9307
|
}
|
|
9055
9308
|
function validateReviewState(on, actions, run, at, path) {
|
|
@@ -9074,6 +9327,7 @@ function validateReviewState(on, actions, run, at, path) {
|
|
|
9074
9327
|
var ON_TYPES, DISARMED_TYPE, PR_ACTIONS, PR_CLOSE_ACTIONS, ISSUE_ACTIONS, REVIEW_STATES, REVIEW_ACTION, ID_CHARSET, REPLICAS_MAX, SECRETS_MAX, ENV_NAME, RESERVED_ENV_NAMES, INSTRUCTIONS_MAX;
|
|
9075
9328
|
var init_triggers = __esm({
|
|
9076
9329
|
"../worker/src/triggers.mjs"() {
|
|
9330
|
+
init_backends();
|
|
9077
9331
|
init_config2();
|
|
9078
9332
|
init_flow_gate();
|
|
9079
9333
|
init_forges();
|
|
@@ -9133,7 +9387,7 @@ function fleetQueueNames(hosts) {
|
|
|
9133
9387
|
function makeQueue(connection, { name = QUEUE } = {}) {
|
|
9134
9388
|
return new Queue(name, { connection });
|
|
9135
9389
|
}
|
|
9136
|
-
async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
9390
|
+
async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, backend, skillsDir, secrets, secretsProfile, chainDepth, parentJobId, jobId, now = /* @__PURE__ */ new Date() }) {
|
|
9137
9391
|
const minute = now.toISOString().slice(0, 16);
|
|
9138
9392
|
const id = jobId ?? localJobId({ folder, flow: command !== void 0 ? `cmd:${command}` : flow, task, minute });
|
|
9139
9393
|
const data = {
|
|
@@ -9149,6 +9403,12 @@ async function enqueueLocalJob(queue, { folder, flow, task, command, provider, m
|
|
|
9149
9403
|
model,
|
|
9150
9404
|
maxTurns,
|
|
9151
9405
|
...image !== void 0 && { image },
|
|
9406
|
+
// #227. WHERE this job's container is built. Conditional like `image`, so a trigger that named no
|
|
9407
|
+
// venue produces byte-identical job data -- and at JOB level, never inside `trigger`, for the reason
|
|
9408
|
+
// `image` and `skillsDir` are: `trigger` is copied VERBATIM into `/job/event.json`, so a key added
|
|
9409
|
+
// there becomes agent-visible input, and where the box was built is the worker's business rather
|
|
9410
|
+
// than the agent's.
|
|
9411
|
+
...backend !== void 0 && { backend },
|
|
9152
9412
|
// The host directory of operator-authored skills this trigger injects (REQ-PER-TRIGGER-SKILLS).
|
|
9153
9413
|
// Conditional like `image`, so an unflagged job's data stays byte-identical, and at JOB level rather
|
|
9154
9414
|
// than inside `trigger` because a worker-host path is an execution knob, not a fact about the
|
|
@@ -9346,8 +9606,8 @@ var init_import_pi = __esm({
|
|
|
9346
9606
|
}
|
|
9347
9607
|
});
|
|
9348
9608
|
|
|
9349
|
-
// ../worker/src/
|
|
9350
|
-
function
|
|
9609
|
+
// ../worker/src/container-spec.mjs
|
|
9610
|
+
function containerSpec({
|
|
9351
9611
|
image,
|
|
9352
9612
|
env,
|
|
9353
9613
|
jobDir,
|
|
@@ -9364,50 +9624,34 @@ function buildDockerRunArgs({
|
|
|
9364
9624
|
if (!image) throw new Error("docker run: image is required");
|
|
9365
9625
|
if (!name) throw new Error("docker run: container name is required");
|
|
9366
9626
|
if (!workspace) throw new Error("docker run: workspace mount is required");
|
|
9367
|
-
const
|
|
9368
|
-
if (
|
|
9369
|
-
|
|
9370
|
-
|
|
9371
|
-
|
|
9372
|
-
|
|
9373
|
-
|
|
9374
|
-
|
|
9375
|
-
|
|
9376
|
-
|
|
9377
|
-
|
|
9378
|
-
|
|
9379
|
-
|
|
9380
|
-
|
|
9627
|
+
const mounts = [];
|
|
9628
|
+
if (jobDir) mounts.push({ host: jobDir, container: "/job", readOnly: true });
|
|
9629
|
+
mounts.push({ host: workspace, container: "/workspace", readOnly: false });
|
|
9630
|
+
if (outboxDir) mounts.push({ host: outboxDir, container: "/outbox", readOnly: false });
|
|
9631
|
+
if (sessionDir) mounts.push({ host: sessionDir, container: CONTAINER_SESSION_DIR, readOnly: false });
|
|
9632
|
+
if (globalPiDir) mounts.push({ host: globalPiDir, container: CONTAINER_GLOBAL_PI_DIR, readOnly: true });
|
|
9633
|
+
return {
|
|
9634
|
+
image,
|
|
9635
|
+
name,
|
|
9636
|
+
memory,
|
|
9637
|
+
cpus,
|
|
9638
|
+
network,
|
|
9639
|
+
// UNCONDITIONALLY true, and there is deliberately no parameter that can unset it. The boundary is
|
|
9640
|
+
// not a thing a caller opts into -- CONST-ISOLATION-CONTAINER-PER-JOB is why every other flag here
|
|
9641
|
+
// exists -- so the spec is simply unable to describe an unisolated container, and the builder in
|
|
9642
|
+
// docker-run.mjs refuses one it is handed. A field that could be false would be a way to ask for less.
|
|
9643
|
+
isolated: true,
|
|
9644
|
+
mounts,
|
|
9645
|
+
env,
|
|
9646
|
+
dockerExtra: extraFlags
|
|
9647
|
+
};
|
|
9381
9648
|
}
|
|
9382
|
-
var CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE
|
|
9383
|
-
var
|
|
9384
|
-
"../worker/src/
|
|
9649
|
+
var CONTAINER_GLOBAL_PI_DIR, CONTAINER_SESSION_DIR, CONTAINER_SESSION_FILE;
|
|
9650
|
+
var init_container_spec = __esm({
|
|
9651
|
+
"../worker/src/container-spec.mjs"() {
|
|
9385
9652
|
CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
|
|
9386
9653
|
CONTAINER_SESSION_DIR = "/session";
|
|
9387
9654
|
CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
|
|
9388
|
-
ISOLATION_FLAGS = [
|
|
9389
|
-
// The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
|
|
9390
|
-
// a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
|
|
9391
|
-
// image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
|
|
9392
|
-
// this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
|
|
9393
|
-
// PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
|
|
9394
|
-
// install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
|
|
9395
|
-
// no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
|
|
9396
|
-
// part that cannot be raced.
|
|
9397
|
-
"--pull=never",
|
|
9398
|
-
"--rm",
|
|
9399
|
-
// ephemeral: gone after the run
|
|
9400
|
-
"--init",
|
|
9401
|
-
// reap zombies (Chromium spawns many); node is PID 1 and does not reap
|
|
9402
|
-
"--cap-drop=ALL",
|
|
9403
|
-
// pi would otherwise inherit the launching user's capabilities
|
|
9404
|
-
"--security-opt",
|
|
9405
|
-
"no-new-privileges",
|
|
9406
|
-
"--pids-limit=512",
|
|
9407
|
-
// bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
|
|
9408
|
-
"--shm-size=1g"
|
|
9409
|
-
// Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
|
|
9410
|
-
];
|
|
9411
9655
|
}
|
|
9412
9656
|
});
|
|
9413
9657
|
|
|
@@ -9491,7 +9735,7 @@ var init_packages = __esm({
|
|
|
9491
9735
|
"../worker/src/packages.mjs"() {
|
|
9492
9736
|
init_config2();
|
|
9493
9737
|
init_import_pi();
|
|
9494
|
-
|
|
9738
|
+
init_container_spec();
|
|
9495
9739
|
PACKAGES_SUBDIR = "packages";
|
|
9496
9740
|
STAGE_MANIFEST = "packages.json";
|
|
9497
9741
|
CONTAINER_PACKAGES_ROOT = `${CONTAINER_GLOBAL_PI_DIR}/${PACKAGES_SUBDIR}`;
|
|
@@ -11408,6 +11652,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11408
11652
|
const command = typeof run.command === "string" ? run.command : null;
|
|
11409
11653
|
const packages = run.packages !== false;
|
|
11410
11654
|
const image = typeof run.image === "string" && run.image.trim() !== "" ? run.image : null;
|
|
11655
|
+
const backend = typeof run.backend === "string" && run.backend.trim() !== "" ? run.backend : null;
|
|
11411
11656
|
const skillsDir = typeof run.skillsDir === "string" && run.skillsDir.trim() !== "" ? run.skillsDir : null;
|
|
11412
11657
|
const instructions = typeof run.instructions === "string" && run.instructions.trim() !== "";
|
|
11413
11658
|
const resume = run.resume === true;
|
|
@@ -11429,6 +11674,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11429
11674
|
model: typeof run.model === "string" ? run.model : null,
|
|
11430
11675
|
packages,
|
|
11431
11676
|
image,
|
|
11677
|
+
backend,
|
|
11432
11678
|
skillsDir,
|
|
11433
11679
|
instructions,
|
|
11434
11680
|
resume,
|
|
@@ -11436,9 +11682,9 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11436
11682
|
secretsProfile
|
|
11437
11683
|
};
|
|
11438
11684
|
case "label":
|
|
11439
|
-
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11685
|
+
return { type: "label", any: normalizeSelector(on.any), all: normalizeSelector(on.all), none: normalizeSelector(on.none), flow, command, packages, image, backend, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11440
11686
|
case "comment":
|
|
11441
|
-
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11687
|
+
return { type: "comment", phrase: typeof on.phrase === "string" ? on.phrase : null, flow, command, packages, image, backend, skillsDir, instructions, resume, secrets, secretsProfile, replicas, forge };
|
|
11442
11688
|
case "pull_request": {
|
|
11443
11689
|
const prDisarmed = on.disarmed !== null && typeof on.disarmed === "object" && !Array.isArray(on.disarmed) ? on.disarmed : null;
|
|
11444
11690
|
return {
|
|
@@ -11454,6 +11700,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11454
11700
|
command,
|
|
11455
11701
|
packages,
|
|
11456
11702
|
image,
|
|
11703
|
+
backend,
|
|
11457
11704
|
skillsDir,
|
|
11458
11705
|
instructions,
|
|
11459
11706
|
resume,
|
|
@@ -11481,6 +11728,7 @@ function normalizeTriggerForDisplay(entry) {
|
|
|
11481
11728
|
command,
|
|
11482
11729
|
packages,
|
|
11483
11730
|
image,
|
|
11731
|
+
backend,
|
|
11484
11732
|
skillsDir,
|
|
11485
11733
|
instructions,
|
|
11486
11734
|
resume,
|
|
@@ -39860,6 +40108,108 @@ var init_pricing = __esm({
|
|
|
39860
40108
|
}
|
|
39861
40109
|
});
|
|
39862
40110
|
|
|
40111
|
+
// ../worker/src/docker-run.mjs
|
|
40112
|
+
function dockerArgsFromSpec(spec) {
|
|
40113
|
+
if (spec?.isolated !== true) throw new Error("docker run: refusing to build an argv for a spec that is not isolated");
|
|
40114
|
+
for (const flag of spec.dockerExtra ?? []) {
|
|
40115
|
+
if (typeof flag !== "string") {
|
|
40116
|
+
throw new Error(`docker run: dockerExtra must contain only strings; got ${typeof flag}`);
|
|
40117
|
+
}
|
|
40118
|
+
if (DOCKER_EXTRA_FORBIDDEN.includes(flag.split("=", 1)[0])) {
|
|
40119
|
+
throw new Error(`docker run: refusing a dockerExtra flag that would supersede the isolation boundary: ${flag}`);
|
|
40120
|
+
}
|
|
40121
|
+
}
|
|
40122
|
+
const args = ["run", `--name=${spec.name}`, ...ISOLATION_FLAGS, `--memory=${spec.memory}`, `--cpus=${spec.cpus}`];
|
|
40123
|
+
if (spec.network) args.push(`--network=${spec.network}`);
|
|
40124
|
+
args.push(...spec.dockerExtra ?? []);
|
|
40125
|
+
for (const [k, v] of Object.entries(spec.env ?? {})) {
|
|
40126
|
+
if (v === void 0 || v === null) continue;
|
|
40127
|
+
args.push("-e", `${k}=${v}`);
|
|
40128
|
+
}
|
|
40129
|
+
for (const m of spec.mounts ?? []) args.push("-v", `${m.host}:${m.container}${m.readOnly ? ":ro" : ""}`);
|
|
40130
|
+
args.push(spec.image);
|
|
40131
|
+
return args;
|
|
40132
|
+
}
|
|
40133
|
+
function buildDockerRunArgs(opts) {
|
|
40134
|
+
return dockerArgsFromSpec(containerSpec(opts));
|
|
40135
|
+
}
|
|
40136
|
+
var ISOLATION_FLAGS, DOCKER_EXTRA_FORBIDDEN;
|
|
40137
|
+
var init_docker_run = __esm({
|
|
40138
|
+
"../worker/src/docker-run.mjs"() {
|
|
40139
|
+
init_container_spec();
|
|
40140
|
+
init_container_spec();
|
|
40141
|
+
ISOLATION_FLAGS = [
|
|
40142
|
+
// The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
|
|
40143
|
+
// a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
|
|
40144
|
+
// image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
|
|
40145
|
+
// this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
|
|
40146
|
+
// PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
|
|
40147
|
+
// install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
|
|
40148
|
+
// no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
|
|
40149
|
+
// part that cannot be raced.
|
|
40150
|
+
"--pull=never",
|
|
40151
|
+
"--rm",
|
|
40152
|
+
// ephemeral: gone after the run
|
|
40153
|
+
"--init",
|
|
40154
|
+
// reap zombies (Chromium spawns many); node is PID 1 and does not reap
|
|
40155
|
+
"--cap-drop=ALL",
|
|
40156
|
+
// pi would otherwise inherit the launching user's capabilities
|
|
40157
|
+
"--security-opt",
|
|
40158
|
+
"no-new-privileges",
|
|
40159
|
+
"--pids-limit=512",
|
|
40160
|
+
// bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
|
|
40161
|
+
"--shm-size=1g"
|
|
40162
|
+
// Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
|
|
40163
|
+
];
|
|
40164
|
+
DOCKER_EXTRA_FORBIDDEN = [
|
|
40165
|
+
// Each of the seven logical flags in ISOLATION_FLAGS, and the argv member beside them that the worker's
|
|
40166
|
+
// own machinery reads back. A flag missing from here is a flag the array cannot defend.
|
|
40167
|
+
"--rm",
|
|
40168
|
+
// `--rm=false` leaves the container behind; verified against docker 27.4.0. `ephemeral` rests on it.
|
|
40169
|
+
"--init",
|
|
40170
|
+
"--shm-size",
|
|
40171
|
+
// The container NAME is not an isolation flag and is the sharpest entry on this list: both boot reapers
|
|
40172
|
+
// match `pi-job-` as a substring and the abort path is `docker stop <name>`, so a second `--name` wins
|
|
40173
|
+
// last and leaves a container no sweep finds and no timeout can stop. `ephemeral` and `abortable` both
|
|
40174
|
+
// rest on it.
|
|
40175
|
+
"--name",
|
|
40176
|
+
"--privileged",
|
|
40177
|
+
"--cap-add",
|
|
40178
|
+
"--security-opt",
|
|
40179
|
+
"--pids-limit",
|
|
40180
|
+
"--memory",
|
|
40181
|
+
"-m",
|
|
40182
|
+
"--cpus",
|
|
40183
|
+
"--network",
|
|
40184
|
+
"--net",
|
|
40185
|
+
"--pull",
|
|
40186
|
+
// Add a mount, which is what `-v`/`--volume`/`--mount` are blocked for; `mountSet` rests on all five.
|
|
40187
|
+
"--volumes-from",
|
|
40188
|
+
"--tmpfs",
|
|
40189
|
+
// Relax the memory bound without repeating `--memory`.
|
|
40190
|
+
"--memory-swap",
|
|
40191
|
+
"--oom-kill-disable",
|
|
40192
|
+
// Widen what the process may do without repeating a flag already listed.
|
|
40193
|
+
"--ulimit",
|
|
40194
|
+
"--sysctl",
|
|
40195
|
+
"--group-add",
|
|
40196
|
+
"--cgroup-parent",
|
|
40197
|
+
"--device-cgroup-rule",
|
|
40198
|
+
"--gpus",
|
|
40199
|
+
"--runtime",
|
|
40200
|
+
"-v",
|
|
40201
|
+
"--volume",
|
|
40202
|
+
"--mount",
|
|
40203
|
+
"--device",
|
|
40204
|
+
"--pid",
|
|
40205
|
+
"--ipc",
|
|
40206
|
+
"--uts",
|
|
40207
|
+
"--userns",
|
|
40208
|
+
"--cgroupns"
|
|
40209
|
+
];
|
|
40210
|
+
}
|
|
40211
|
+
});
|
|
40212
|
+
|
|
39863
40213
|
// ../worker/src/sandbox-store.mjs
|
|
39864
40214
|
import { lstatSync, mkdirSync, readFileSync as readFileSync3, readdirSync as readdirSync2, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
39865
40215
|
import { isAbsolute as isAbsolute3, join as join5, relative } from "node:path";
|
|
@@ -40175,10 +40525,11 @@ var init_render = __esm({
|
|
|
40175
40525
|
});
|
|
40176
40526
|
|
|
40177
40527
|
// ../worker/src/scheduler-stall-guard.mjs
|
|
40178
|
-
var STALL_KEY, STALL_WINDOW_SECONDS;
|
|
40528
|
+
var STALL_KEY, stallKey, STALL_WINDOW_SECONDS;
|
|
40179
40529
|
var init_scheduler_stall_guard = __esm({
|
|
40180
40530
|
"../worker/src/scheduler-stall-guard.mjs"() {
|
|
40181
40531
|
STALL_KEY = "pi-dispatch:sched-stalls";
|
|
40532
|
+
stallKey = (schedulerId) => `${STALL_KEY}:${schedulerId}`;
|
|
40182
40533
|
STALL_WINDOW_SECONDS = 24 * 60 * 60;
|
|
40183
40534
|
}
|
|
40184
40535
|
});
|
|
@@ -40444,7 +40795,9 @@ function createDashboardDeps(paths, {
|
|
|
40444
40795
|
async fetchSnapshot() {
|
|
40445
40796
|
const queues = await fleetQueues();
|
|
40446
40797
|
const merged = await mergedRunsOn(redis, { logsDir: paths.logsDir, limit: RUNS_ON_DASHBOARD }).catch(() => ({ runs: [], hosts: [], mirror: "off" }));
|
|
40447
|
-
const
|
|
40798
|
+
const triggersView = readTriggers({ triggersPath: paths.triggersPath });
|
|
40799
|
+
const cronIds = Array.isArray(triggersView?.triggers) ? triggersView.triggers.filter((t) => t?.type === "cron" && typeof t?.id === "string" && t.id !== "").map((t) => t.id) : [];
|
|
40800
|
+
const [pausedStates, countsPer, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerLists, activeLists, stallCounts] = await Promise.all([
|
|
40448
40801
|
Promise.all(queues.map((q) => q.isPaused())),
|
|
40449
40802
|
Promise.all(queues.map((q) => q.getJobCounts("waiting", "active", "paused", "delayed", "failed"))),
|
|
40450
40803
|
queue.getWorkers().catch(() => []),
|
|
@@ -40458,8 +40811,10 @@ function createDashboardDeps(paths, {
|
|
|
40458
40811
|
Promise.all(queues.map((q, i) => i === 0 ? q.getJobSchedulers(0, -1, true) : q.getJobSchedulers(0, -1, true).catch(() => []))),
|
|
40459
40812
|
Promise.all(queues.map((q) => q.getActive(0, 0).catch(() => []))),
|
|
40460
40813
|
// Per-scheduler stall counts (money backstop) for the cron drill-in; reuses the held client like the
|
|
40461
|
-
// budget GETs.
|
|
40462
|
-
|
|
40814
|
+
// budget GETs. ONE MGET for every declared cron id, so this stays one round trip however many
|
|
40815
|
+
// schedulers exist. An absent key reads as an honest 0 -- a scheduler that has never stalled -- which
|
|
40816
|
+
// is the same posture `readScopedBudget` takes for a scope that has never run.
|
|
40817
|
+
cronIds.length > 0 ? redis.mget(...cronIds.map((id) => stallKey(id))).catch(() => []) : Promise.resolve([])
|
|
40463
40818
|
]);
|
|
40464
40819
|
const workers = Array.isArray(workerList) && workerList.length > 0 ? workerList.length : "unknown";
|
|
40465
40820
|
const counts = {};
|
|
@@ -40492,7 +40847,9 @@ function createDashboardDeps(paths, {
|
|
|
40492
40847
|
queue: { pausedState, pausedPartial, counts, workers, queues: queues.length, fleetDegraded },
|
|
40493
40848
|
budget: { day: Number(dayRaw ?? 0), week: Number(weekRaw ?? 0), month: Number(monthRaw ?? 0), tokensToday: Number(tokenRaw ?? 0) },
|
|
40494
40849
|
schedulers: mapSchedulers(schedulerList, Date.now()),
|
|
40495
|
-
|
|
40850
|
+
// Rebuilt into the `{ id -> count }` shape the drill-in and the LIST badge already index by, so
|
|
40851
|
+
// neither reader changes.
|
|
40852
|
+
schedulerStalls: Object.fromEntries(cronIds.map((id, i) => [id, Number(stallCounts?.[i] ?? 0) || 0])),
|
|
40496
40853
|
schedulerStallMax: paths.schedulerStallMax,
|
|
40497
40854
|
// Merged across the fleet (issue #57, Gap 3). This host's files stay the truth and the single-host
|
|
40498
40855
|
// path; the mirror adds the other hosts' records. On a shared `PI_LOGS_DIR` the local read is
|
|
@@ -40501,7 +40858,7 @@ function createDashboardDeps(paths, {
|
|
|
40501
40858
|
runHosts: merged.hosts,
|
|
40502
40859
|
runMirror: merged.mirror,
|
|
40503
40860
|
settings: readSettingsView({ settingsFile: paths.settingsFile }),
|
|
40504
|
-
triggers:
|
|
40861
|
+
triggers: triggersView,
|
|
40505
40862
|
pauseWindows: readPauseWindows({ pauseWindowsPath: paths.pauseWindowsPath }),
|
|
40506
40863
|
// The operator's staged third-party pi packages (REQ-GLOBAL-PI-OVERLAY), for the armed triggers'
|
|
40507
40864
|
// trust model. Like the four reads above it is a plain file read whose fs access lives entirely in
|
|
@@ -41383,6 +41740,7 @@ function renderTriggerDetail(t, inner, styler, sched = null, staged = null) {
|
|
|
41383
41740
|
}
|
|
41384
41741
|
if (typeof t.command === "string" && t.command.trim() !== "") out.push(kv("command", `/${t.command.trim()}`, "accent"));
|
|
41385
41742
|
out.push(kv("image", t.image ?? "deployment default", t.image ? "accent" : "dim"));
|
|
41743
|
+
out.push(kv("backend", t.backend ?? "deployment default", t.backend ? "accent" : "dim"));
|
|
41386
41744
|
out.push(kv("resume", t.resume === true ? "continues the previous session" : "cold start", t.resume === true ? "warning" : "dim"));
|
|
41387
41745
|
out.push(kv("replicas", t.replicas > 1 ? `${t.replicas} sandboxes race this flow` : "one run per delivery", t.replicas > 1 ? "warning" : "dim"));
|
|
41388
41746
|
blank();
|
|
@@ -42247,7 +42605,7 @@ var init_setup_wizard = __esm({
|
|
|
42247
42605
|
init_deployment_pointer();
|
|
42248
42606
|
init_read_model();
|
|
42249
42607
|
init_index();
|
|
42250
|
-
RUNTIME_VERSION = "1.
|
|
42608
|
+
RUNTIME_VERSION = "1.10.0";
|
|
42251
42609
|
RECEIVER_VERSION = "1.5.0";
|
|
42252
42610
|
RUNTIME_PKG = "@edgehero/pi-dispatch";
|
|
42253
42611
|
RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
|
|
@@ -42558,7 +42916,7 @@ function registerTools(pi) {
|
|
|
42558
42916
|
pi.registerTool({
|
|
42559
42917
|
name: "dispatch_trigger_add",
|
|
42560
42918
|
label: "pi-dispatch add trigger",
|
|
42561
|
-
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|issue. 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. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. 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.",
|
|
42919
|
+
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|issue. 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. issue fires when an ISSUE closes: it needs flow, its action[] defaults to the forge's close word (github/forgejo closed, gitlab close; azure has no close trigger), and it may set `number` (the forge's own item number, narrowing the rule to that one issue) and `once: true` (a one-shot: it fires once, the run is recorded, then the worker disarms the entry by writing on.disarmed; once requires number). A close-only pull_request rule accepts the same number/once narrowing. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted|closed, gitlab is open|update|reopen|approved|close, forgejo is label_updated|opened|synchronized|reopened|closed, azure is created|updated (no close word). The close word rides alone: it cannot be mixed with other actions in one entry. An azure label or comment trigger must also set `repository` (a work item belongs to a project, not a repository), and an azure pull_request trigger may not carry labels[] at all. A github review_submitted trigger may also set reviewState[] (approved|changes_requested|commented) to narrow which verdicts fire; omitted, all three do. 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. `backend` (optional, any kind) names WHERE the job's container is built, chosen from the names this deployment blesses in PI_BACKENDS; omitted, the job runs on the deployment default. A name this deployment does not bless is refused here, and refused again before the job spends.",
|
|
42562
42920
|
executionMode: "sequential",
|
|
42563
42921
|
parameters: typebox_exports.Object({
|
|
42564
42922
|
kind: typebox_exports.String(),
|
|
@@ -42582,9 +42940,20 @@ function registerTools(pi) {
|
|
|
42582
42940
|
once: typebox_exports.Optional(typebox_exports.Boolean()),
|
|
42583
42941
|
model: typebox_exports.Optional(typebox_exports.String()),
|
|
42584
42942
|
provider: typebox_exports.Optional(typebox_exports.String()),
|
|
42585
|
-
maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 }))
|
|
42943
|
+
maxTurns: typebox_exports.Optional(typebox_exports.Integer({ minimum: 1 })),
|
|
42944
|
+
// #227. WHICH venue this trigger's container is built in. A NAME chosen from what PI_BACKENDS
|
|
42945
|
+
// blessed, never a configuration -- `DES-PER-TRIGGER-JOB-IMAGE` predicted this exact arrival: "If a
|
|
42946
|
+
// future tool ever takes an image parameter, the allowlist arrives with that tool, and this row is
|
|
42947
|
+
// the reason it must." The allowlist is checked in execute, below, because a schema enum would be
|
|
42948
|
+
// frozen at registration while PI_BACKENDS is read per call.
|
|
42949
|
+
backend: typebox_exports.Optional(typebox_exports.String())
|
|
42586
42950
|
}),
|
|
42587
42951
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42952
|
+
const blessed = blessedBackends(process.env);
|
|
42953
|
+
if (params.backend !== void 0) {
|
|
42954
|
+
if (blessed.length === 0) throw new Error("this deployment blesses no backends, so run.backend cannot be set (see PI_BACKENDS)");
|
|
42955
|
+
if (!blessed.includes(params.backend)) throw new Error(`backend '${params.backend}' is not blessed by this deployment (PI_BACKENDS: ${blessed.join(", ")})`);
|
|
42956
|
+
}
|
|
42588
42957
|
const entry = buildTriggerEntry(params.kind, params);
|
|
42589
42958
|
if (!entry) throw new Error(`unknown trigger kind '${params.kind}' (cron|label|comment|pull_request|issue)`);
|
|
42590
42959
|
const result = await confirmedWrite(
|
|
@@ -42603,9 +42972,9 @@ ${JSON.stringify(entry)}` },
|
|
|
42603
42972
|
pi.registerTool({
|
|
42604
42973
|
name: "dispatch_trigger_edit",
|
|
42605
42974
|
label: "pi-dispatch edit trigger",
|
|
42606
|
-
description: "Changes which flow a trigger runs (by array index from dispatch_triggers) and applies it live. The operator MUST approve a confirm dialog showing
|
|
42975
|
+
description: "Changes which flow a trigger runs (by array index from dispatch_triggers) and applies it live, and optionally which `backend` (venue) its container is built in, chosen from the names PI_BACKENDS blesses. The operator MUST approve a confirm dialog showing the before->after; with no interactive operator it is refused.",
|
|
42607
42976
|
executionMode: "sequential",
|
|
42608
|
-
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }), flow: typebox_exports.String() }),
|
|
42977
|
+
parameters: typebox_exports.Object({ index: typebox_exports.Integer({ minimum: 0 }), flow: typebox_exports.String(), backend: typebox_exports.Optional(typebox_exports.String()) }),
|
|
42609
42978
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
42610
42979
|
const paths = resolvePaths(process.env);
|
|
42611
42980
|
const list = triggerList(paths);
|
|
@@ -42613,16 +42982,25 @@ ${JSON.stringify(entry)}` },
|
|
|
42613
42982
|
if (!cur) throw new Error(`no trigger at index ${params.index} (have ${list.length})`);
|
|
42614
42983
|
const flow = params.flow.trim();
|
|
42615
42984
|
if (!flow) throw new Error("flow must be non-empty");
|
|
42985
|
+
const backend = optStr(params.backend);
|
|
42986
|
+
if (backend !== void 0) {
|
|
42987
|
+
const blessed = blessedBackends(process.env);
|
|
42988
|
+
if (blessed.length === 0) throw new Error("this deployment blesses no backends, so run.backend cannot be set (see PI_BACKENDS)");
|
|
42989
|
+
if (!blessed.includes(backend)) throw new Error(`backend '${backend}' is not blessed by this deployment (PI_BACKENDS: ${blessed.join(", ")})`);
|
|
42990
|
+
}
|
|
42991
|
+
const venue = backend === void 0 ? "" : `, backend: ${cur.backend ?? "-"} -> ${backend}`;
|
|
42616
42992
|
const result = await confirmedWrite(
|
|
42617
42993
|
ctx,
|
|
42618
|
-
{ title: `Edit trigger #${params.index + 1}`, message: `trigger #${params.index + 1} (${cur.type}) flow: ${cur.flow ?? "-"} -> ${flow}` },
|
|
42994
|
+
{ title: `Edit trigger #${params.index + 1}`, message: `trigger #${params.index + 1} (${cur.type}) flow: ${cur.flow ?? "-"} -> ${flow}${venue}` },
|
|
42619
42995
|
() => {
|
|
42620
42996
|
const res = writeTriggers({
|
|
42621
42997
|
triggersPath: paths.triggersPath,
|
|
42622
|
-
|
|
42998
|
+
// `backend` is merged only when the caller sent one, so an edit that changes just the flow
|
|
42999
|
+
// leaves the entry byte-identical in every other key -- including a venue it already had.
|
|
43000
|
+
mutate: (raw) => raw.map((tr, i) => i === params.index ? { ...tr, run: { ...tr.run, flow, ...backend !== void 0 && { backend } } } : tr)
|
|
42623
43001
|
});
|
|
42624
43002
|
if (res.invalid) throw new Error(`rejected: ${res.invalid}`);
|
|
42625
|
-
return { applied: true, index: params.index, flow };
|
|
43003
|
+
return { applied: true, index: params.index, flow, ...backend !== void 0 && { backend } };
|
|
42626
43004
|
}
|
|
42627
43005
|
);
|
|
42628
43006
|
return toolText(JSON.stringify(result));
|
|
@@ -42919,6 +43297,13 @@ function triggerList(paths) {
|
|
|
42919
43297
|
const t = readTriggers({ triggersPath: paths.triggersPath });
|
|
42920
43298
|
return Array.isArray(t?.triggers) ? t.triggers : [];
|
|
42921
43299
|
}
|
|
43300
|
+
function blessedBackends(env) {
|
|
43301
|
+
try {
|
|
43302
|
+
return parseBackendList(env.PI_BACKENDS);
|
|
43303
|
+
} catch {
|
|
43304
|
+
return [];
|
|
43305
|
+
}
|
|
43306
|
+
}
|
|
42922
43307
|
function buildTriggerEntry(kind, f) {
|
|
42923
43308
|
if (kind === "cron") {
|
|
42924
43309
|
const run = { kind: "local", folder: f.folder, flow: f.flow, task: f.task };
|
|
@@ -42928,11 +43313,14 @@ function buildTriggerEntry(kind, f) {
|
|
|
42928
43313
|
if (model) run.model = model;
|
|
42929
43314
|
if (provider) run.provider = provider;
|
|
42930
43315
|
if (maxTurns !== void 0) run.maxTurns = maxTurns;
|
|
43316
|
+
const backend2 = optStr(f.backend);
|
|
43317
|
+
if (backend2) run.backend = backend2;
|
|
42931
43318
|
return { on: { type: "cron", id: f.id, pattern: f.pattern }, run };
|
|
42932
43319
|
}
|
|
42933
43320
|
const forge = optStr(f.forge) ?? "github";
|
|
42934
43321
|
const repository = optStr(f.repository);
|
|
42935
|
-
const
|
|
43322
|
+
const backend = optStr(f.backend);
|
|
43323
|
+
const forgeRun = (rest) => ({ kind: forge, ...rest, ...repository ? { repository } : {}, ...backend ? { backend } : {} });
|
|
42936
43324
|
if (kind === "label") return { on: { type: "label", any: asWords(f.labels ?? f.any) }, run: forgeRun({ flow: f.flow }) };
|
|
42937
43325
|
if (kind === "comment") return { on: { type: "comment", phrase: f.phrase }, run: forgeRun({ flow: f.flow }) };
|
|
42938
43326
|
if (kind === "pull_request") {
|
|
@@ -42942,7 +43330,7 @@ function buildTriggerEntry(kind, f) {
|
|
|
42942
43330
|
const prNumber = optInt(f.number);
|
|
42943
43331
|
if (prNumber !== void 0) on.number = prNumber;
|
|
42944
43332
|
if (typeof f.once === "boolean") on.once = f.once;
|
|
42945
|
-
return { on, run: { kind: forge, flow: f.flow } };
|
|
43333
|
+
return { on, run: { kind: forge, flow: f.flow, ...backend ? { backend } : {} } };
|
|
42946
43334
|
}
|
|
42947
43335
|
if (kind === "issue") {
|
|
42948
43336
|
const action = asWords(f.action);
|
|
@@ -42950,7 +43338,7 @@ function buildTriggerEntry(kind, f) {
|
|
|
42950
43338
|
const number = optInt(f.number);
|
|
42951
43339
|
if (number !== void 0) on.number = number;
|
|
42952
43340
|
if (typeof f.once === "boolean") on.once = f.once;
|
|
42953
|
-
return { on, run: { kind: forge, flow: f.flow } };
|
|
43341
|
+
return { on, run: { kind: forge, flow: f.flow, ...backend ? { backend } : {} } };
|
|
42954
43342
|
}
|
|
42955
43343
|
return null;
|
|
42956
43344
|
}
|
|
@@ -43809,6 +44197,7 @@ var init_index = __esm({
|
|
|
43809
44197
|
init_read_model();
|
|
43810
44198
|
init_graph_model();
|
|
43811
44199
|
init_insights_html();
|
|
44200
|
+
init_backends();
|
|
43812
44201
|
init_open_browser();
|
|
43813
44202
|
init_budget();
|
|
43814
44203
|
init_deployment_pointer();
|
|
@@ -43865,6 +44254,7 @@ export {
|
|
|
43865
44254
|
SUPPORTED_PI_VERSION,
|
|
43866
44255
|
USED_API,
|
|
43867
44256
|
_setPiVersionAdvisoryForTests,
|
|
44257
|
+
blessedBackends,
|
|
43868
44258
|
buildTriggerEntry,
|
|
43869
44259
|
computePiVersionAdvisory,
|
|
43870
44260
|
admin as default,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch-admin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Operator console (a pi extension) + skill for pi-dispatch: run the pi coding agent as a self-hosted service. A /dispatch TUI for the queue, spend caps, run history, editable GitHub, 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",
|