@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.
Files changed (2) hide show
  1. package/dist/index.mjs +458 -68
  2. 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/docker-run.mjs
9350
- function buildDockerRunArgs({
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 args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`];
9368
- if (network) args.push(`--network=${network}`);
9369
- args.push(...extraFlags);
9370
- for (const [k, v] of Object.entries(env ?? {})) {
9371
- if (v === void 0 || v === null) continue;
9372
- args.push("-e", `${k}=${v}`);
9373
- }
9374
- if (jobDir) args.push("-v", `${jobDir}:/job:ro`);
9375
- args.push("-v", `${workspace}:/workspace`);
9376
- if (outboxDir) args.push("-v", `${outboxDir}:/outbox`);
9377
- if (sessionDir) args.push("-v", `${sessionDir}:${CONTAINER_SESSION_DIR}`);
9378
- if (globalPiDir) args.push("-v", `${globalPiDir}:${CONTAINER_GLOBAL_PI_DIR}:ro`);
9379
- args.push(image);
9380
- return args;
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, ISOLATION_FLAGS;
9383
- var init_docker_run = __esm({
9384
- "../worker/src/docker-run.mjs"() {
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
- init_docker_run();
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 [pausedStates, countsPer, workerList, dayRaw, weekRaw, monthRaw, tokenRaw, schedulerLists, activeLists, stallHash] = await Promise.all([
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. HGETALL of an absent key is `{}`, so a never-stalled deployment shows 0 stalls.
40462
- redis.hgetall(STALL_KEY).catch(() => ({}))
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
- schedulerStalls: stallHash ?? {},
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: readTriggers({ triggersPath: paths.triggersPath }),
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.8.0";
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 flow before->after; with no interactive operator it is refused.",
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
- mutate: (raw) => raw.map((tr, i) => i === params.index ? { ...tr, run: { ...tr.run, flow } } : tr)
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 forgeRun = (rest) => ({ kind: forge, ...rest, ...repository ? { repository } : {} });
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.8.0",
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",