@edgehero/pi-dispatch 1.10.0 → 1.10.2

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/.env.example CHANGED
@@ -86,12 +86,22 @@ PI_JOB_IMAGE=pi-job:latest # the DEFAULT job image. Any trigger may nam
86
86
  # PI_SUBSCRIPTIONS_FILE= # path to subscriptions.json — operator-declared subscription plan prices (the admin defaults to ./subscriptions.json in its working directory). Read by the ADMIN EXTENSION only, never at job time.
87
87
  # Subscription-backed providers bill 0 per run (their rate tables are all zeros), so this file is where the real price lives — cost analytics only; it changes no routing, auth, or job behavior
88
88
  # PI_SETTINGS_FILE= # ABSOLUTE path to the runtime settings overlay (default: OS temp /pi-dispatch/settings.json); edited by the admin extension, read by the worker per job
89
+ # PI_DISPATCH_DEPLOYMENT_FILE= # ABSOLUTE path to the deployment pointer the /dispatch panel reads to find a deployment built elsewhere
90
+ # (default: <your pi agent dir>/pi-dispatch-deployment.json). Read by the ADMIN EXTENSION only; the worker and receiver never look at it.
91
+ # Your own environment still wins key by key, so this points the panel at a deployment, it does not override one
92
+ # PI_GRAPH_DIR= # where `/dispatch insights` writes its HTML artifact (default: under the OS temp dir). Admin extension only. See docs/insights.md
89
93
 
90
94
  # --- Reuse your existing pi setup in every job (see docs/global-pi-overlay.md) ---
91
95
  # PI_GLOBAL_PI_DIR= # dir with your host pi setup (models.json/skills/APPEND_SYSTEM.md), mounted /opt/pi-global:ro into every job, layered UNDER each repo's .pi/. Unset = off. Stage it with: pi-dispatch import-pi
92
96
  # PI_GLOBAL_ALLOW_EXTENSIONS= # the overlay's extensions LOAD by default (staging them with import-pi, which prints each one, is the vetting step). Set exactly 0 to keep them staged but dormant.
93
97
  # Unset, empty and the legacy 1 all mean LOAD. ANY other value refuses to boot -- a typo must never silently leave code running against adversarial input with open egress.
94
98
  # This knob covers the OVERLAY only. A serviced repo's own /workspace/.pi/extensions load regardless (they are default-branch, merge-gated) -- see SECURITY.md.
99
+ # PI_CODING_AGENT_DIR= # where YOUR pi setup lives on this host (default: ~/.pi/agent). `pi-dispatch import-pi` reads its models.json,
100
+ # skills and APPEND_SYSTEM.md from here, and the panel looks here for the deployment pointer.
101
+ # It is also read AT JOB TIME: with no provider key in the environment the worker reads this directory's auth.json
102
+ # for one (on by default; PI_AUTH_FROM_PI=0 turns it off), so pointing this at the wrong place makes every job
103
+ # refuse pre-spend with no credential for the provider. It is the SOURCE that gets staged, never the thing mounted:
104
+ # PI_GLOBAL_PI_DIR above is what a job actually sees. Set it if your pi lives somewhere other than your home directory
95
105
  # PI_PACKAGES_FILE= # path to pi-packages.json (default: ./pi-packages.json; --packages-file wins). Read ONLY by `pi-dispatch import-pi --with-packages`, never at job time.
96
106
  # Staged packages/ rides INSIDE PI_GLOBAL_PI_DIR -- no separate mount, no separate env dir -- and loads for EVERY job once staged; decline it PER TRIGGER with "packages": false in triggers.json, NOT by an env flag.
97
107
  # Versions must be EXACT (no ^ ~ * or latest); staging uses --ignore-scripts, so a package needing a build step is staged INCOMPLETE and import-pi warns.
@@ -166,6 +176,12 @@ RECEIVER_BIND=0.0.0.0
166
176
  GITHUB_AUTH_SOURCE=gh
167
177
  # For GITHUB_AUTH_SOURCE=pat: a repo-scoped, short-expiry fine-grained PAT
168
178
  GITHUB_PAT=
179
+ # GITHUB_PAT_VAR= # which variable above actually holds the PAT. Default GITHUB_PAT; set this only if your
180
+ # secrets manager insists on its own name and you would rather not copy the value to a second key.
181
+ # The NAME is not checked against anything: whatever you put here is read verbatim, so a typo
182
+ # reads an empty variable and the worker refuses at boot naming the name you chose. Pointing it
183
+ # at a variable that holds something else (GITHUB_APP_PRIVATE_KEY, say) is the mistake worth
184
+ # knowing about, because nothing stops it and the PAT path would then send that value to GitHub.
169
185
  # For GITHUB_AUTH_SOURCE=app (optional; required for multi-tenant)
170
186
  # `pi-dispatch setup github` fills all three in one browser click (App Manifest flow) and writes the PEM 0600
171
187
  GITHUB_APP_ID=
@@ -176,6 +192,23 @@ GITHUB_APP_PRIVATE_KEY_PATH=
176
192
  # escapes both work. Never list it in PI_FORWARD_ENV: it mints tokens for every repo the App is on.
177
193
  GITHUB_APP_PRIVATE_KEY=
178
194
 
195
+ # --- Polling ingest, instead of a webhook (GitHub only) --- issue #282
196
+ # `pi-dispatch-receiver poll` fetches issue events, comments and pull requests over TLS with your own
197
+ # credential, so a deployment with no public URL, no DNS and no tunnel still fires triggers. Same gates and
198
+ # same queue as the webhook path; about a minute of latency instead of a second. Nothing here is read by
199
+ # `pi-dispatch-receiver serve`, and no WEBHOOK_SECRET is needed to poll: there is no inbound delivery to
200
+ # verify, because the poller originates every request itself. See docs/polling.md.
201
+ # POLL_REPOS= # WHICH repos to watch: comma-separated owner/name (e.g. acme/web,acme/api). Duplicates are dropped.
202
+ # Each entry must be exactly owner/name -- one slash, no spaces -- or the receiver refuses at boot naming the bad entry.
203
+ # Leave it UNSET only with GITHUB_AUTH_SOURCE=app: the poller then lists the App installation's own repos and
204
+ # re-lists every tenth cycle, so installing the App on a new repo starts polling it without an edit here.
205
+ # Unset under any other auth source is a boot refusal, deliberately: a PAT names no repo set, and a poller
206
+ # watching nothing looks exactly like a poller that is working.
207
+ # POLL_INTERVAL_SECONDS= # seconds between cycles. Default 60, floored at 30: a positive value BELOW the floor is raised to it,
208
+ # while 0, a negative, a fraction or junk still refuses at boot.
209
+ # GitHub asks pollers to respect its own x-poll-interval hint, which is honored as a MINIMUM when it arrives,
210
+ # so a busy hour slows the loop down rather than the loop hammering the API. A typo'd 1 must not turn this into a hammer.
211
+
179
212
  # --- GitLab trigger (receiver + worker auth) ---
180
213
  # Optional. Set these only to service GitLab projects; leaving GITLAB_TOKEN unset means no /gitlab
181
214
  # endpoint exists at all, rather than one that answers 401. See docs/gitlab.md.
@@ -184,6 +217,13 @@ GITHUB_APP_PRIVATE_KEY=
184
217
  # scope that can post a note -- GitLab offers no contents-vs-issues split -- so scope it to one project
185
218
  # and rotate it (CONST-TOKEN-SCOPED-PER-JOB). A GROUP token reaches every project in the group.
186
219
  GITLAB_TOKEN=
220
+ # GITLAB_AUTH_SOURCE= # accepts exactly one value, "pat", which is also the default, so there is nothing to set here.
221
+ # It exists to REFUSE the wrong assumption rather than to offer a choice: GITHUB_AUTH_SOURCE has
222
+ # three sources, and an operator who reasons by symmetry and writes app here gets a sentence at
223
+ # boot saying GitLab has no App equivalent, instead of a knob that is silently ignored.
224
+ # The refusal needs GITLAB_TOKEN to be set: with no token there is no GitLab to configure, the
225
+ # whole block is skipped, and a stray app here really is ignored. Same for the two below.
226
+ # FORGEJO_AUTH_SOURCE and AZURE_AUTH_SOURCE are the same variable for the same reason.
187
227
  # Your instance root. Only for self-hosted GitLab.
188
228
  GITLAB_URL=https://gitlab.com
189
229
  # How the receiver verifies a delivery. REQUIRED once any GITLAB_* variable is set, and deliberately not
@@ -205,6 +245,7 @@ FORGEJO_URL=
205
245
  # expire: there is no App or installation token, so rotation is the whole mitigation
206
246
  # (CONST-TOKEN-SCOPED-PER-JOB).
207
247
  FORGEJO_TOKEN=
248
+ # FORGEJO_AUTH_SOURCE= # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
208
249
  # The harness account's NUMERIC id. Required when the token above is repository-scoped, because such a
209
250
  # token may not carry read:user and therefore cannot call GET /user. The receiver refuses to boot without an
210
251
  # identity from one source or the other: the bot-loop guard compares against it, and an unresolved identity
@@ -223,6 +264,7 @@ AZURE_ORG_URL=
223
264
  # permissions in Project Settings -- not from the token's scopes. It also needs vso.graph, to resolve the
224
265
  # actor's project membership before a job may be enqueued.
225
266
  AZURE_TOKEN=
267
+ # AZURE_AUTH_SOURCE= # only "pat", the default. See GITLAB_AUTH_SOURCE above for why it exists at all.
226
268
  # REQUIRED once any AZURE_* variable is set, and deliberately not defaulted: both modes are shared-secret
227
269
  # compares that cover no bytes, so which header carries the secret must be a choice somebody made.
228
270
  # basic -- Authorization: Basic <base64>, the credential you set on the subscription
@@ -232,3 +274,41 @@ AZURE_WEBHOOK_MODE=
232
274
  AZURE_WEBHOOK_SECRET=
233
275
  # Required only when AZURE_WEBHOOK_MODE=header.
234
276
  AZURE_WEBHOOK_HEADER=
277
+
278
+ # --- What is deliberately NOT a key in this file --- issue #282
279
+ # The rule, and it is checked by a test (worker/test/env-docs.test.mjs): every environment variable this
280
+ # project's loaders read is either a key above, or named here with the reason it cannot be one. A variable
281
+ # that is read and appears in neither is the failure this section exists to prevent, because an operator has
282
+ # no way to discover it and no way to find out that setting it did nothing.
283
+ #
284
+ # The worker's own inputs to a job container: PI_JOB_ID, PI_FLOW, PI_COMMAND, PI_PACKAGES, PI_SESSION_FILE,
285
+ # PI_OFFLINE, and the three PLAYWRIGHT_ names. The container's environment is BUILT, not inherited: the worker
286
+ # passes exactly the map it composed, so a value set here never reaches a job to be overridden in the first
287
+ # place. Several of them are also conditional, present only when the job has a flow, a command, staged
288
+ # packages or a session to resume. These are the container's side of INT-CONTAINER-RUNTIME-CONTRACT
289
+ # (specs/interfaces.md, docs/job-image.md), and run.secrets refuses the names at load so a trigger cannot
290
+ # bind one either. PI_FORWARD_ENV is NOT checked against these names, and it is applied after the worker's
291
+ # own map, so naming one there does override it. Not everything is: run.secrets, the egress variables and
292
+ # the minted forge token are all written later still and win over a forwarded value. A real edge, not a
293
+ # recommendation.
294
+ #
295
+ # PI_ENV_SETUP is an argument to `pi-dispatch service render|install --env-setup <absolute path>`, never a
296
+ # key. The service wrappers capture it BEFORE they source ./.env, precisely so that anything able to write
297
+ # this file cannot name a script the wrapper will run as the worker. A line here is honored by nothing, and
298
+ # that is the point rather than an oversight (docs/secrets.md, REQ-DEPLOYMENT-BOOTSTRAP).
299
+ #
300
+ # PI_RETRY_MAX (default 2) and PI_RETRY_BASE_MS (default 2000) are read by the runner INSIDE the container,
301
+ # and nothing on the host writes them, so a line here sets them on this machine and never reaches a job.
302
+ # PI_FORWARD_ENV is what carries a host value into a container, and is how you would actually change them.
303
+ #
304
+ # Provider key names are pi's, not ours. The worker asks pi which variable your PI_PROVIDER expects, so the
305
+ # provider block at the top of this file lists the common ones as examples and is not the whole set: pi
306
+ # supports around thirty providers and the answer travels with pi rather than with this file.
307
+ #
308
+ # Read from the surrounding system, not from a deployment: TMPDIR and TEMP (where the default job, log,
309
+ # graph and settings paths go), USER (the account a rendered service unit runs as), and TERM, SSH_CONNECTION,
310
+ # SSH_TTY, DISPLAY, WAYLAND_DISPLAY (how the panel decides whether it can open a browser for you).
311
+ #
312
+ # PI_DISPATCH_REQUIRE_LOADER_TESTS, PI_DISPATCH_REQUIRE_WORKER_TESTS, PI_DISPATCH_REQUIRE_RECEIVER_TESTS and
313
+ # VALKEY_TEST_URL turn locally skipped integration tests into required ones. They are read by the test files
314
+ # themselves and by CI, never by the worker, the receiver or the panel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "type": "module",
5
5
  "description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
6
6
  "keywords": [
package/src/config.mjs CHANGED
@@ -498,6 +498,9 @@ export function loadGitHubAuth(env, fileExists) {
498
498
  return { source, patVar, appId, installationId, privateKeyPath, privateKey };
499
499
  }
500
500
 
501
+ // env-internal TMPDIR, TEMP: the OS temp dir, read to place the default job, log, graph and settings
502
+ // paths below. Not a variable of this project's and not a deployment knob: PI_JOBS_DIR, PI_LOGS_DIR,
503
+ // PI_GRAPH_DIR and PI_SETTINGS_FILE are how an operator moves any of them, and .env.example says so.
501
504
  function defaultJobsDir() {
502
505
  // Under the OS temp dir by default. Holds only the read-only /job inputs (prompt + .pi/); the
503
506
  // workspace for a local job is the operator's own folder, not here.
package/src/doctor.mjs CHANGED
@@ -2072,6 +2072,9 @@ async function envSetupChecks(env, seams) {
2072
2072
  }
2073
2073
  }
2074
2074
 
2075
+ // env-internal PI_ENV_SETUP: unit configuration, deliberately never an .env key. The wrappers capture
2076
+ // it BEFORE they source ./.env so that nothing able to write that file can name a script they run
2077
+ // (REQ-DEPLOYMENT-BOOTSTRAP). doctor reads it here only to answer for a host whose unit names none.
2075
2078
  const fromEnv = (env.PI_ENV_SETUP ?? "").trim();
2076
2079
  if (sources.size === 0 && fromEnv) sources.set(fromEnv, "PI_ENV_SETUP in this environment");
2077
2080
 
package/src/index.mjs CHANGED
@@ -693,12 +693,23 @@ export function makeProcessor({ cancelJob, stopContainer, containerName = (job)
693
693
  // job.data with no `.id` -- and the real wrapper is only in scope here, so inject it as
694
694
  // `queueJobId`, mirroring the collectChain injection above. Omitted when unwired so a bare
695
695
  // processor keeps runJob's plain (job, token) call.
696
- ...(deps.prepareWorkspace ? { prepareWorkspace: (j, t) => deps.prepareWorkspace(j, t, { queueJobId: job.id }) } : {}),
696
+ // The third argument is EXTENDED, never replaced. `runJob` calls this as
697
+ // `prepareWorkspace(job, token, { piVersion })`, so a wrapper passing only `{ queueJobId }`
698
+ // dropped it -- and `piVersion` defaults to `null`, which `readCanonical` treats as
699
+ // "never resume": `if (piVersion === null) return COLD("pi-version-changed")`. So EVERY
700
+ // `run.resume` cold-started, on every wired worker, while reporting success. The stamp
701
+ // `promoteSession` writes was correct the whole time; the comparison simply never happened.
702
+ // The processor tests inject `prepareWorkspace` directly and never see this wrapper, which is
703
+ // why nothing caught it (REQ-RESUMABLE-SESSION).
704
+ ...(deps.prepareWorkspace ? { prepareWorkspace: (j, t, opts) => deps.prepareWorkspace(j, t, { ...opts, queueJobId: job.id }) } : {}),
697
705
  // The one-shot pre-spend check (issue #231) needs the REAL BullMQ job's `.id` to excuse this
698
706
  // delivery's own earlier attempt -- runJob's effectiveJob has no `.id`, prepareWorkspace's
699
707
  // own injection above states why, and this one mirrors it. Omitted when unwired so a bare
700
708
  // processor keeps runJob's admit-everything default.
701
- ...(deps.checkOnceSpent ? { checkOnceSpent: (j) => deps.checkOnceSpent(j, { queueJobId: job.id }) } : {}),
709
+ // Same extend-don't-replace shape as `prepareWorkspace` above, for its reason: `runJob` passes
710
+ // this one no options today, so there is nothing to lose yet -- and the day it does, a
711
+ // replacing wrapper would lose it in the same silence.
712
+ ...(deps.checkOnceSpent ? { checkOnceSpent: (j, opts) => deps.checkOnceSpent(j, { ...opts, queueJobId: job.id }) } : {}),
702
713
  });
703
714
  recordRun({ job, result, startedAt, endedAt: new Date().toISOString() });
704
715
  return result;
@@ -140,6 +140,8 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
140
140
  workspace: resolved.manifest.workspace,
141
141
  jobDir: resolved.manifest.dir,
142
142
  publish,
143
+ // env-internal TERM: the operator's own terminal type, forwarded so the sandbox shell renders the
144
+ // way their terminal does. Nothing a deployment declares.
143
145
  term: env.TERM,
144
146
  idleSeconds: config.sandboxIdleMinutes * 60,
145
147
  network,
package/src/service.mjs CHANGED
@@ -265,6 +265,8 @@ export async function runService(argv = [], deps = {}) {
265
265
  moduleDir = MODULE_DIR,
266
266
  resolveReceiver = resolveReceiverStart,
267
267
  home = homedir(),
268
+ // env-internal USER: whose account a rendered unit runs as, taken from the login already running
269
+ // this command. An operator changes it by running the command as someone else, not by declaring it.
268
270
  user = env.USER || userInfo().username,
269
271
  tmp = tmpdir(),
270
272
  fs = { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync },