@edgehero/pi-dispatch-admin 1.0.0 → 1.1.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # pi-dispatch: run the pi coding agent as a self-hosted service
6
6
 
7
- **Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you.
7
+ **Let a coding agent work on your repositories while you are not watching, without surprise bills and without giving it the keys to your machine.** [pi](https://github.com/earendil-works/pi) is a superb coding agent, but it has no job queue, no spend limit, and, by its own README, no permission system. pi-dispatch adds exactly those, so you can run pi unattended and safely: on a cron schedule, on your repo's issues and PRs, or straight from the CLI. Every job runs in an isolated container, spend is bounded before a single token is spent, and everything the agent did (and what it cost) is recorded, graphed and priced for you. On a forge you stay in the loop at both ends: a job starts only when someone with write access to the repo asks for one, by a label, an `@pi` comment or a review, and what comes back is a pull request you review and land, because pi-dispatch never merges anything. This is not another agent to weigh against the one you already use; it is the queue, the budget and the box for the pi you already run.
8
8
 
9
9
  > This npm package, **`@edgehero/pi-dispatch-admin`**, is the **operator console** (a pi extension). The service itself is `@edgehero/pi-dispatch` (worker + CLI) and `@edgehero/pi-dispatch-receiver` (the webhook edge); the [main repo](https://github.com/edgehero/pi-dispatch) has the container image, docs, and SECURITY.md.
10
10
 
package/dist/index.mjs CHANGED
@@ -7598,6 +7598,12 @@ var init_build = __esm({
7598
7598
  }
7599
7599
  });
7600
7600
 
7601
+ // ../worker/src/egress.mjs
7602
+ var init_egress = __esm({
7603
+ "../worker/src/egress.mjs"() {
7604
+ }
7605
+ });
7606
+
7601
7607
  // ../worker/src/forges.mjs
7602
7608
  function isForgeKind(kind) {
7603
7609
  return typeof kind === "string" && Object.hasOwn(FORGES, kind);
@@ -7698,6 +7704,7 @@ function defaultSettingsFile() {
7698
7704
  var CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT;
7699
7705
  var init_config2 = __esm({
7700
7706
  "../worker/src/config.mjs"() {
7707
+ init_egress();
7701
7708
  init_forges();
7702
7709
  CHAIN_DEPTH_MAX_DEFAULT = 1;
7703
7710
  CHAIN_MAX_PER_JOB_DEFAULT = 2;
@@ -8071,9 +8078,6 @@ function validateReplicas(run, at, path) {
8071
8078
  if (run.kind === "local") {
8072
8079
  throw configError(`${at}: run.replicas is not available on a cron trigger -- a local job's /workspace IS the operator's folder, bind-mounted read-write, so two replicas would edit one working tree with no gate and no undo: ${path}`);
8073
8080
  }
8074
- if (run.kind !== "github") {
8075
- throw configError(`${at}: run.replicas is not yet covered for ${run.kind} triggers (github only in this version); every forge mints its branch the same way, so this is a gap to close, not a limit: ${path}`);
8076
- }
8077
8081
  if (!Number.isInteger(replicas) || replicas < 2 || replicas > REPLICAS_MAX) {
8078
8082
  throw configError(`${at}: run.replicas must be an integer between 2 and ${REPLICAS_MAX} when present -- ${REPLICAS_MAX} is the ceiling because PI_CONCURRENCY defaults to 3, so a further replica would queue instead of racing, and 1 is refused because a one-member replica set is a flag that does nothing (got ${JSON.stringify(replicas)}): ${path}`);
8079
8083
  }
@@ -8700,12 +8704,15 @@ function buildDockerRunArgs({
8700
8704
  name,
8701
8705
  memory = "4g",
8702
8706
  cpus = "2",
8707
+ network = null,
8703
8708
  extraFlags = []
8704
8709
  }) {
8705
8710
  if (!image) throw new Error("docker run: image is required");
8706
8711
  if (!name) throw new Error("docker run: container name is required");
8707
8712
  if (!workspace) throw new Error("docker run: workspace mount is required");
8708
- const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
8713
+ const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`];
8714
+ if (network) args.push(`--network=${network}`);
8715
+ args.push(...extraFlags);
8709
8716
  for (const [k, v] of Object.entries(env ?? {})) {
8710
8717
  if (v === void 0 || v === null) continue;
8711
8718
  args.push("-e", `${k}=${v}`);
@@ -38768,7 +38775,7 @@ import { promisify as promisify4 } from "node:util";
38768
38775
  function sandboxContainerName(jobId) {
38769
38776
  return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
38770
38777
  }
38771
- function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
38778
+ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0, network = null, egressEnv: proxyEnv = {} }) {
38772
38779
  return buildDockerRunArgs({
38773
38780
  image,
38774
38781
  name,
@@ -38777,9 +38784,20 @@ function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], ter
38777
38784
  // The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
38778
38785
  // forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
38779
38786
  // disabled idle timeout emits nothing rather than an empty string.
38787
+ // A sandbox joins the SAME kind of network a job did, by the same builder, so the boundary cannot
38788
+ // land on job containers and miss this one. Leaving sandboxes on the default bridge was the tempting
38789
+ // alternative and it is the wrong one: it reads as a convenience (install a missing dependency while
38790
+ // debugging) and it is a WIDER reach than the run the sandbox exists to reproduce. A shell that can
38791
+ // go where the run could not is not reproducing the run. Nothing an operator wants is lost, because
38792
+ // the forge and the registry are on the allowlist a job needed anyway.
38793
+ network,
38780
38794
  env: {
38781
38795
  TERM: term || void 0,
38782
- TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0
38796
+ TMOUT: idleSeconds > 0 ? String(idleSeconds) : void 0,
38797
+ // Still NO CREDENTIALS, and that clause is untouched: a proxy URL is not a credential, and
38798
+ // buildContainerEnv is still not reused here. The env is two variables about the terminal and,
38799
+ // when a policy is armed, three about the network.
38800
+ ...proxyEnv
38783
38801
  },
38784
38802
  // Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
38785
38803
  // the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
@@ -40231,7 +40249,11 @@ function renderRunDetail(record, inner, styler, allRuns = [], sandbox = null) {
40231
40249
  out.push(kv("chain", chainBits.join(" \xB7 ")));
40232
40250
  if (r.replica > 0) {
40233
40251
  const sibs = (Array.isArray(allRuns) ? allRuns : []).filter(
40234
- (x) => x?.replica > 0 && x.replica !== r.replica && x.target === r.target && x.flow === r.flow
40252
+ // `kind` is load-bearing since #187, not decoration: `target` is repo + separator + number, and
40253
+ // targetFor renders github and forgejo with the SAME `#`, so a github `o/r#5` and a forgejo `o/r#5`
40254
+ // are one string. While only github could replicate, no two replica runs could collide; now they can,
40255
+ // and naming a stranger's job as your sibling is worse than naming none.
40256
+ (x) => x?.replica > 0 && x.replica !== r.replica && x.kind === r.kind && x.target === r.target && x.flow === r.flow
40235
40257
  );
40236
40258
  const repBits = [`r${r.replica}/${r.replicas ?? "?"}`];
40237
40259
  repBits.push(sibs.length > 0 ? `sibling ${sibs.map((s) => `r${s.replica} ${s.jobId}`).join(", ")}` : "no sibling in this window");
@@ -40845,8 +40867,8 @@ var init_setup_wizard = __esm({
40845
40867
  init_deployment_pointer();
40846
40868
  init_read_model();
40847
40869
  init_index();
40848
- RUNTIME_VERSION = "1.0.0";
40849
- RECEIVER_VERSION = "1.0.0";
40870
+ RUNTIME_VERSION = "1.1.0";
40871
+ RECEIVER_VERSION = "1.1.0";
40850
40872
  RUNTIME_PKG = "@edgehero/pi-dispatch";
40851
40873
  RECEIVER_PKG = "@edgehero/pi-dispatch-receiver";
40852
40874
  NUDGE_MARKER_BASENAME = "pi-dispatch-setup.nudged";
@@ -41051,12 +41073,16 @@ function registerTools(pi) {
41051
41073
  pi.registerTool({
41052
41074
  name: "dispatch_trigger_add",
41053
41075
  label: "pi-dispatch add trigger",
41054
- description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved. 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.",
41076
+ description: "Adds a trigger to triggers.json and applies it live. The operator MUST approve a confirm dialog showing the entry; with no interactive operator it is refused. `flow` is the .pi/skills/<name> skill the job runs (its SKILL.md is the agent's instructions). `kind` = cron|label|comment|pull_request. cron (local) needs id, pattern, `folder` (absolute host path the job runs in), `flow`, and `task` (the prompt text handed to the agent), and may set optional model/provider/maxTurns for that schedule (omit = deployment default). label needs labels[]+flow; comment needs phrase+flow; pull_request needs action[] (+ optional labels[]) + flow. Webhook triggers take an optional `forge` = github (default) | gitlab | forgejo | azure, which also decides which action words pull_request accepts: github is labeled|opened|synchronize|reopened|review_submitted, gitlab is open|update|reopen|approved, forgejo is label_updated|opened|synchronized|reopened, azure is created|updated. 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.",
41055
41077
  executionMode: "sequential",
41056
41078
  parameters: typebox_exports.Object({
41057
41079
  kind: typebox_exports.String(),
41058
41080
  flow: typebox_exports.String(),
41059
41081
  forge: typebox_exports.Optional(typebox_exports.String()),
41082
+ // buildTriggerEntry has always READ `f.repository`, and the schema has always stripped it -- so an
41083
+ // azure label/comment trigger was unauthorable by the model, refused at the write for want of a field
41084
+ // it had no way to send. Surfaced by #187 widening what the description above must say about forges.
41085
+ repository: typebox_exports.Optional(typebox_exports.String()),
41060
41086
  id: typebox_exports.Optional(typebox_exports.String()),
41061
41087
  pattern: typebox_exports.Optional(typebox_exports.String()),
41062
41088
  folder: typebox_exports.Optional(typebox_exports.String()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch-admin",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -122,14 +122,16 @@ job time; and `pi-dispatch doctor` lists every image their triggers name and fla
122
122
 
123
123
  ## The forge a trigger listens to — `run.kind`
124
124
 
125
- A webhook trigger names its forge: `"kind": "github"` or `"kind": "gitlab"`. Everything else about the
126
- trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`, `image`.
125
+ A webhook trigger names its forge: `"kind"` is `github`, `gitlab`, `forgejo` or `azure`. Everything else
126
+ about the trigger is the same — the `on.type`, the `{any, all, none}` label predicate, `flow`, `packages`,
127
+ `image`, `replicas`.
127
128
 
128
- Two things are NOT the same, and both refuse at load rather than misbehaving quietly:
129
+ Three things are NOT the same, and all of them refuse at load rather than misbehaving quietly:
129
130
 
130
131
  - **`pull_request` actions are the forge's own words.** GitHub takes
131
132
  `labeled | opened | synchronize | reopened | review_submitted`; GitLab takes
132
- `open | update | reopen | approved`. A word
133
+ `open | update | reopen | approved`; Forgejo takes
134
+ `label_updated | opened | synchronized | reopened`; Azure takes `created | updated`. A word
133
135
  from the wrong forge is refused when the file is written. It would not break anything otherwise — it
134
136
  would simply never match an event, and the trigger would look configured while doing nothing.
135
137
  `review_submitted` is GitHub's `pull_request_review` event: it fires on **every** submitted review, so
@@ -138,6 +140,29 @@ Two things are NOT the same, and both refuse at load rather than misbehaving qui
138
140
  load, because a narrowing that cannot apply reads as one that does.
139
141
  - **One `comment` trigger per forge.** Two GitHub comment triggers are refused; one GitHub and one GitLab
140
142
  are fine.
143
+ - **Azure names no repository.** A work item belongs to a project, so an azure `label` or `comment` trigger
144
+ MUST set `run.repository` (the repo within the project to clone) and every other forge's must not. An
145
+ azure `pull_request` trigger may not carry a label predicate at all: Azure tags work items, never pull
146
+ requests, so `any`/`all`/`none` could never match and a rule that loads clean and never fires reads as a
147
+ broken harness.
148
+
149
+ ## Racing two agents on one event — `run.replicas`
150
+
151
+ `"replicas": 2` on a `label`, `comment` or `pull_request` trigger turns one delivery into **two independent
152
+ paid jobs**: two containers, two branches (`pi/issue-7-r1` and `-r2`), two review requests, one human
153
+ picking. It works on every forge. Absent, nothing changes.
154
+
155
+ Three things to say when an operator asks about it:
156
+
157
+ - **It multiplies spend, and the caps are the only ceiling.** Each replica reserves its own budget slot
158
+ before its own tokens, so a `replicas: 2` trigger firing ten times a day consumes twenty slots of the
159
+ daily cap, not ten. Nothing is discounted; that is the feature, not an oversight.
160
+ - **You cannot set it from here.** There is no `replicas` parameter on `dispatch_trigger_add` or
161
+ `_edit`, and no panel key. It is a reviewed edit to `triggers.json`, deliberately: a spend multiplier is
162
+ plainly a capability a model should not gain. Say so rather than looking for a way around it.
163
+ - **It refuses on cron, and beside `resume`.** A local job's `/workspace` IS the operator's folder, so two
164
+ replicas would edit one working tree with no gate and no undo. And a resumed run continues one lineage
165
+ where replicas exist to fork it.
141
166
 
142
167
  `dispatch_trigger_add` takes an optional `forge` parameter, defaulting to `github`. Unlike `image`, this
143
168
  one IS offered to the model — a model that can already add a GitHub trigger can already arm a paid run,