@edgehero/pi-dispatch 0.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.
Files changed (69) hide show
  1. package/.env.example +160 -0
  2. package/deploy/com.pi-dispatch.worker.plist +66 -0
  3. package/deploy/nssm-install.cmd +59 -0
  4. package/deploy/receiver.service +36 -0
  5. package/deploy/worker-env-wrapper.cmd +50 -0
  6. package/deploy/worker-env-wrapper.sh +63 -0
  7. package/deploy/worker.service +55 -0
  8. package/package.json +83 -0
  9. package/src/azure-auth.mjs +61 -0
  10. package/src/azure-host.mjs +236 -0
  11. package/src/azure-identity.mjs +63 -0
  12. package/src/azure-prompt.mjs +118 -0
  13. package/src/branch.mjs +80 -0
  14. package/src/budget.mjs +179 -0
  15. package/src/cli.mjs +208 -0
  16. package/src/config.mjs +329 -0
  17. package/src/connection.mjs +40 -0
  18. package/src/cron.mjs +94 -0
  19. package/src/docker-run.mjs +119 -0
  20. package/src/doctor.mjs +1127 -0
  21. package/src/env-allowlist.mjs +198 -0
  22. package/src/env-file.mjs +153 -0
  23. package/src/exit-code.mjs +32 -0
  24. package/src/flow-gate.mjs +82 -0
  25. package/src/forgejo-auth.mjs +77 -0
  26. package/src/forgejo-host.mjs +172 -0
  27. package/src/forgejo-identity.mjs +74 -0
  28. package/src/forgejo-prompt.mjs +123 -0
  29. package/src/forges.mjs +148 -0
  30. package/src/get-token.mjs +226 -0
  31. package/src/git-dirty.mjs +16 -0
  32. package/src/github-app-setup.mjs +517 -0
  33. package/src/github-host.mjs +159 -0
  34. package/src/github-prompt.mjs +286 -0
  35. package/src/gitlab-auth.mjs +72 -0
  36. package/src/gitlab-host.mjs +200 -0
  37. package/src/gitlab-identity.mjs +61 -0
  38. package/src/gitlab-prompt.mjs +123 -0
  39. package/src/identity.mjs +57 -0
  40. package/src/image-preflight.mjs +180 -0
  41. package/src/import-pi.mjs +451 -0
  42. package/src/index.mjs +177 -0
  43. package/src/init.mjs +77 -0
  44. package/src/job-id.mjs +100 -0
  45. package/src/materialize.mjs +138 -0
  46. package/src/outbox.mjs +179 -0
  47. package/src/packages.mjs +188 -0
  48. package/src/pause-windows.mjs +218 -0
  49. package/src/prepare-github.mjs +260 -0
  50. package/src/prepare-local.mjs +76 -0
  51. package/src/prepare.mjs +199 -0
  52. package/src/pricing.mjs +168 -0
  53. package/src/processor.mjs +360 -0
  54. package/src/queue.mjs +152 -0
  55. package/src/run-container.mjs +133 -0
  56. package/src/run-history.mjs +534 -0
  57. package/src/runtime-settings.mjs +188 -0
  58. package/src/sandbox-cli.mjs +156 -0
  59. package/src/sandbox-store.mjs +269 -0
  60. package/src/sandbox.mjs +171 -0
  61. package/src/scheduler-stall-guard.mjs +67 -0
  62. package/src/schedules.mjs +62 -0
  63. package/src/service.mjs +677 -0
  64. package/src/session-key.mjs +108 -0
  65. package/src/session-store.mjs +249 -0
  66. package/src/start.mjs +502 -0
  67. package/src/subscriptions.mjs +208 -0
  68. package/src/triggers.mjs +491 -0
  69. package/src/up.mjs +315 -0
package/src/config.mjs ADDED
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Worker configuration, from the environment. Validated and fail-loud: a misconfigured worker
3
+ * should refuse to start with a clear message, not launch and fail per-job.
4
+ *
5
+ * Errors are tagged `piDispatchConfig` so the CLI/entry can print them cleanly and exit non-zero.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { delimiter } from "node:path";
10
+ import { MINTED_TOKEN_VARS } from "./forges.mjs";
11
+
12
+ export function configError(message) {
13
+ const error = new Error(message);
14
+ error.piDispatchConfig = true;
15
+ return error;
16
+ }
17
+
18
+ function boundedInt(env, name, fallback, min, want) {
19
+ const raw = env[name];
20
+ if (raw === undefined || raw === "") {
21
+ if (fallback !== undefined) return fallback;
22
+ throw configError(`missing required env: ${name}`);
23
+ }
24
+ const n = Number.parseInt(raw, 10);
25
+ if (!Number.isInteger(n) || n < min || String(n) !== String(raw).trim()) {
26
+ throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want ${want})`);
27
+ }
28
+ return n;
29
+ }
30
+
31
+ export function positiveInt(env, name, fallback) {
32
+ return boundedInt(env, name, fallback, 1, "a positive integer");
33
+ }
34
+
35
+ // min=0: accepts 0 (a sentinel, e.g. "keep forever" for log retention), still rejects negatives and non-integers.
36
+ function nonNegativeInt(env, name, fallback) {
37
+ return boundedInt(env, name, fallback, 0, "a non-negative integer");
38
+ }
39
+
40
+ // An OPTIONAL bounded int: an unset or empty var is `null` (the feature it gates is disabled), a present
41
+ // one is validated in `[min, max]` (max undefined = no upper bound) and otherwise a config error. Unlike
42
+ // `boundedInt`, absence is a first-class "off", not a fallback default -- used by the optional week/month
43
+ // spend ceilings and the soft-hold band, which default to disabled rather than to a number.
44
+ function optionalBoundedInt(env, name, min, max) {
45
+ const raw = env[name];
46
+ if (raw === undefined || raw === "") return null;
47
+ const n = Number.parseInt(raw, 10);
48
+ const inRange = Number.isInteger(n) && n >= min && (max === undefined || n <= max) && String(n) === String(raw).trim();
49
+ if (!inRange) {
50
+ const want = max === undefined ? `an integer >= ${min}` : `an integer ${min}-${max}`;
51
+ throw configError(`invalid ${name}: ${JSON.stringify(raw)} (want ${want})`);
52
+ }
53
+ return n;
54
+ }
55
+
56
+ // Split a PATH-style list on the OS path delimiter (`;` on Windows, `:` elsewhere) so a Windows
57
+ // drive-letter colon is not mistaken for a separator. Trims, drops empties. Entries are stored
58
+ // verbatim; downstream (task 3.1) realpaths them, so no posix normalisation happens here.
59
+ function delimitedList(raw) {
60
+ return (raw ?? "")
61
+ .split(delimiter)
62
+ .map((s) => s.trim())
63
+ .filter((s) => s.length > 0);
64
+ }
65
+
66
+ // A comma-separated list of NAMES (env var names cannot contain commas, so unlike a path list this
67
+ // splits on ",", not the OS path delimiter). Used by PI_FORWARD_ENV.
68
+ function commaList(raw) {
69
+ return (raw ?? "")
70
+ .split(",")
71
+ .map((s) => s.trim())
72
+ .filter((s) => s.length > 0);
73
+ }
74
+
75
+ // PI_FORWARD_ENV, with the token names the worker itself owns refused at boot. env-allowlist.mjs sets
76
+ // them from the per-job mint; forwarding one from the host would silently swap which credential every job
77
+ // spends, so this fails loud here rather than per-job.
78
+ //
79
+ // Derived from the forge table rather than written out, because this list and the mint that writes those
80
+ // names have to agree and used to be two hand-maintained lists twenty lines apart in different files. A
81
+ // forge added to the mint but missed here is not refused -- so an operator could forward a long-lived host
82
+ // token under that name into every container of every forge, with nothing failing and nothing logged.
83
+ // `forges.mjs` derives both from one row, and `env-allowlist.test.mjs` binds them.
84
+
85
+ function forwardEnvList(raw) {
86
+ const names = commaList(raw);
87
+ const minted = names.filter((n) => MINTED_TOKEN_VARS.has(n));
88
+ if (minted.length > 0) {
89
+ throw configError(
90
+ `PI_FORWARD_ENV must not forward ${minted.join(", ")} -- the worker mints a per-job token (CONST-TOKEN-SCOPED-PER-JOB) and a forwarded operator token would silently override it`,
91
+ );
92
+ }
93
+ return names;
94
+ }
95
+
96
+ // The operator's global pi overlay dir (REQ-GLOBAL-PI-OVERLAY). Unset/empty = feature off. When set it
97
+ // must EXIST at boot -- a typo pointing at nothing would silently drop the operator's whole setup on
98
+ // every job, so fail loud like every other config error rather than degrade to nothing.
99
+ function resolveGlobalPiDir(env, fileExists) {
100
+ const dir = env.PI_GLOBAL_PI_DIR;
101
+ if (dir === undefined || dir === "") return null;
102
+ if (!fileExists(dir)) throw configError(`PI_GLOBAL_PI_DIR does not exist: ${dir}`);
103
+ return dir;
104
+ }
105
+
106
+ /**
107
+ * Does the overlay's `extensions/` dir load in job containers (REQ-GLOBAL-PI-OVERLAY)?
108
+ *
109
+ * ON by default. The operator vetted this code twice already -- once by having it in their own `~/.pi/agent`,
110
+ * once by staging it into the overlay with `import-pi` -- so a third gate is friction, not safety, and the
111
+ * setup they staged is the setup a job should get. `PI_GLOBAL_ALLOW_EXTENSIONS` survives only as the
112
+ * opt-OUT: the exact string "0" disables them. Unset, empty, and the legacy "1" all mean LOAD, so an .env
113
+ * that still carries the old arming flag keeps working and says the same thing it always did.
114
+ *
115
+ * Any OTHER value is a config error, not a default in either direction. The strict-parse discipline is
116
+ * unchanged, but the thing it now defends against has flipped: under the old fail-closed reading a typo
117
+ * degraded to "dormant", which was merely disappointing; now the damaging misreading is "the operator
118
+ * believes they turned extensions off and they are still loading into every adversarial-input container".
119
+ * `PI_GLOBAL_ALLOW_EXTENSIONS=false` must therefore refuse to boot rather than be quietly ignored. "0" is
120
+ * the canonical opt-out because it is the exact inverse of the "1" already written in existing .env files
121
+ * and matches the single-character discipline of PI_AUTH_FROM_PI=0 / PI_CAPTURE_JOB_LOGS=1.
122
+ *
123
+ * Exported so `doctor` reports the same reading the worker will boot with (it deliberately re-reads env
124
+ * defaults rather than calling loadConfig, which throws on unrelated GitHub-auth problems).
125
+ */
126
+ export function globalExtensionsEnabled(env) {
127
+ const raw = env.PI_GLOBAL_ALLOW_EXTENSIONS;
128
+ if (raw === undefined || raw === "" || raw === "1") return true;
129
+ if (raw === "0") return false;
130
+ throw configError(
131
+ `invalid PI_GLOBAL_ALLOW_EXTENSIONS: ${JSON.stringify(raw)} (want "0" to disable the overlay's extensions, or leave it unset to load them)`,
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Parse the worker's config from `env` (default process.env). Every MONEY default is conservative:
137
+ * spend controls (`PI_DAILY_CAP`, `PI_MAX_TURNS`) exist to bound money, so they default low, and a
138
+ * cap of 0 would fail closed (budget.mjs refuses every job) rather than mean "unlimited". The optional
139
+ * week/month ceilings and the soft-hold band default to disabled (`null`) -- the mandatory daily cap is
140
+ * always the primary money bound; the others are additive ceilings an operator opts into.
141
+ *
142
+ * The operator's OWN staged setup is the deliberate exception: `allowGlobalExtensions` defaults to ON
143
+ * (REQ-GLOBAL-PI-OVERLAY). Staging is itself the vetting step, so the overlay an operator built is the
144
+ * overlay their jobs get, and the knob is an opt-out. That relaxation stops there -- it never touches the
145
+ * spend caps above, the per-job token scoping, or the admin-extension recursion block.
146
+ */
147
+ export function loadConfig(env = process.env, { fileExists = existsSync } = {}) {
148
+ const model = env.PI_MODEL ?? "claude-sonnet-4-5-20250929"; // dated snapshot; deterministic per CONST-PI-VERSION-PINNED
149
+ return {
150
+ valkeyUrl: env.VALKEY_URL ?? "redis://127.0.0.1:6379",
151
+ concurrency: positiveInt(env, "PI_CONCURRENCY", 3), // DES-CONCURRENCY-3
152
+ dailyCap: positiveInt(env, "PI_DAILY_CAP", 25), // bounds container STARTS per day (money)
153
+ weeklyCap: optionalBoundedInt(env, "PI_WEEKLY_CAP", 1), // REQ-SPEND-CAPS-MULTI-WINDOW; null = weekly window disabled
154
+ monthlyCap: optionalBoundedInt(env, "PI_MONTHLY_CAP", 1), // null = monthly window disabled
155
+ softHoldPct: optionalBoundedInt(env, "PI_SOFT_HOLD_PCT", 1, 99), // null = soft-hold band disabled
156
+ provider: env.PI_PROVIDER ?? "anthropic",
157
+ model,
158
+ maxTurns: positiveInt(env, "PI_MAX_TURNS", 30), // pi has no turn limit; we impose one
159
+ maxTokens: optionalBoundedInt(env, "PI_MAX_TOKENS", 1), // issue #25; null = per-job token budget disabled (lagging in-run backstop)
160
+ dailyTokenCap: optionalBoundedInt(env, "PI_DAILY_TOKEN_CAP", 1), // issue #25; null = daily token counter disabled (check-AFTER, host-side)
161
+ jobImage: env.PI_JOB_IMAGE || "pi-job:latest", // || (not ??) so an empty string falls back; "" is falsy and would throw inside buildDockerRunArgs AFTER a budget slot was reserved
162
+ globalPiDir: resolveGlobalPiDir(env, fileExists), // REQ-GLOBAL-PI-OVERLAY: operator's ~/.pi/agent subset, :ro-mounted; null = off
163
+ allowGlobalExtensions: globalExtensionsEnabled(env), // REQ-GLOBAL-PI-OVERLAY: ON unless PI_GLOBAL_ALLOW_EXTENSIONS=0
164
+ forwardEnv: forwardEnvList(env.PI_FORWARD_ENV), // extra host var NAMES to forward (e.g. a custom provider's key); explicit allowlist, GitHub token names refused
165
+ authFromPi: env.PI_AUTH_FROM_PI !== "0", // ON by default: use the key in ~/.pi/agent/auth.json when the env has none (api-key only). PI_AUTH_FROM_PI=0 forces env-only.
166
+ jobsDir: env.PI_JOBS_DIR ?? defaultJobsDir(),
167
+ // REQ-RESURRECTABLE-SANDBOX. `||` (not `??`) so an empty string falls back, matching logsDir.
168
+ sandboxDir: env.PI_SANDBOX_DIR || defaultSandboxDir(env),
169
+ // Hours a finished run's directory stays re-openable. NOTE THE SENTINEL, which is the OPPOSITE of
170
+ // logRetentionDays' and sessionsTtlDays': 0 means the feature is OFF (nothing is retained, cleanup
171
+ // is the `rm` it always was), NOT keep-forever. There is deliberately no keep-forever value -- a
172
+ // full repository clone per run with no ceiling is a disk bomb, and `--pin` exists for the one run
173
+ // worth keeping longer, bounded by sandboxPinDays.
174
+ sandboxRetentionHours: nonNegativeInt(env, "PI_SANDBOX_RETENTION_HOURS", 24),
175
+ sandboxPinDays: nonNegativeInt(env, "PI_SANDBOX_PIN_DAYS", 7), // `--pin` extends to now + this, never to forever
176
+ sandboxIdleMinutes: nonNegativeInt(env, "PI_SANDBOX_IDLE_MINUTES", 30), // bash's own TMOUT inside a sandbox; 0 = no idle logout
177
+ triggersFile: env.PI_TRIGGERS_FILE ?? null, // DES-CRON-VIA-BULLMQ-SCHEDULER: unified triggers file; null = cron disabled for the worker (it selects on.type:"cron")
178
+ pauseWindowsFile: env.PI_PAUSE_WINDOWS_FILE ?? null, // REQ-SCOPED-PAUSE-WINDOWS: per-folder/repo timed pause; null = no scoped pauses
179
+ schedulerStallMax: positiveInt(env, "PI_SCHEDULER_STALL_MAX", 2), // CONST-RETRY-INFRA-ONLY: per-scheduler stall backstop; positiveInt rejects <1 so a 0 threshold fails closed
180
+ logsDir: env.PI_LOGS_DIR || defaultLogsDir(), // || (not ??) so an empty string falls back to the default
181
+ settingsFile: env.PI_SETTINGS_FILE || defaultSettingsFile(), // || (not ??) so an empty string falls back; INT-CONFIG-OVERLAY-CONTRACT
182
+ captureJobLogs: env.PI_CAPTURE_JOB_LOGS === "1", // no-pii-in-logs: raw job-log capture is opt-in; anything but "1" is off
183
+ logRetentionDays: nonNegativeInt(env, "PI_LOG_RETENTION_DAYS", 30), // 0 = keep forever
184
+ // REQ-RESUMABLE-SESSION. NO DEFAULT, deliberately unlike logsDir/jobsDir: unset means the feature
185
+ // is unavailable, and a trigger that armed run.resume then refuses PRE-SPEND rather than running
186
+ // silently without persistence. A transcript is the most PII-bearing artifact this system holds --
187
+ // tool output, file contents, the agent's own reasoning -- and defaulting it into <OS temp>, which
188
+ // is mode 1777 on POSIX, is not a place to put that by accident.
189
+ sessionsDir: env.PI_SESSIONS_DIR || null,
190
+ sessionsTtlDays: nonNegativeInt(env, "PI_SESSIONS_TTL_DAYS", 14), // 0 = keep forever
191
+ // A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
192
+ // oversized transcript is a prefill an operator never sized PI_MAX_TOKENS for.
193
+ sessionMaxBytes: nonNegativeInt(env, "PI_SESSION_MAX_BYTES", 8 * 1024 * 1024), // 0 = no cap
194
+ chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX", 1), // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
195
+ chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB", 2), // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
196
+ dispatchRunPerHour: nonNegativeInt(env, "PI_DISPATCH_RUN_PER_HOUR", 3), // DES-ADMIN-VIA-PI-EXTENSION; 0 = disable dispatch_run
197
+ dispatchRunRoots: delimitedList(env.PI_DISPATCH_RUN_ROOTS), // DES-AI-TRIGGER-FLOW-GATE: default [] fails closed — no folder passes, dispatch_run refuses everything
198
+ github: { ...loadGitHubAuth(env, fileExists), allowGhResume: env.PI_SESSIONS_ALLOW_GH_SOURCE === "1" },
199
+ gitlab: loadGitLabAuth(env),
200
+ forgejo: loadForgejoAuth(env),
201
+ azure: loadAzureAuth(env),
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Parse and validate the GitHub auth block consumed verbatim by `makeGitHubAuth(cfg)` in
207
+ * get-token.mjs. Shape is fixed: `{ source, patVar, appId, installationId, privateKeyPath }`.
208
+ * Fails loud at load time so a misconfigured worker refuses to boot rather than failing per-job.
209
+ */
210
+ export function loadGitHubAuth(env, fileExists) {
211
+ const source = env.GITHUB_AUTH_SOURCE ?? "gh";
212
+ if (source !== "pat" && source !== "gh" && source !== "app") {
213
+ throw configError(`invalid GITHUB_AUTH_SOURCE: ${source} (expected pat|gh|app)`);
214
+ }
215
+
216
+ const patVar = env.GITHUB_PAT_VAR ?? "GITHUB_PAT";
217
+ const appId = env.GITHUB_APP_ID;
218
+ const installationId = env.GITHUB_APP_INSTALLATION_ID;
219
+ const privateKeyPath = env.GITHUB_APP_PRIVATE_KEY_PATH;
220
+
221
+ if (source === "pat") {
222
+ const pat = (env[patVar] ?? "").trim();
223
+ if (!pat) {
224
+ throw configError(`GITHUB_AUTH_SOURCE=pat requires a non-empty ${patVar}`);
225
+ }
226
+ }
227
+
228
+ if (source === "app") {
229
+ const missing = [];
230
+ if (!appId) missing.push("GITHUB_APP_ID");
231
+ if (!installationId) missing.push("GITHUB_APP_INSTALLATION_ID");
232
+ if (!privateKeyPath) missing.push("GITHUB_APP_PRIVATE_KEY_PATH");
233
+ if (missing.length > 0) {
234
+ throw configError(`GITHUB_AUTH_SOURCE=app requires ${missing.join(", ")}`);
235
+ }
236
+ if (!fileExists(privateKeyPath)) {
237
+ throw configError(`GITHUB_APP_PRIVATE_KEY_PATH does not exist: ${privateKeyPath}`);
238
+ }
239
+ }
240
+
241
+ return { source, patVar, appId, installationId, privateKeyPath };
242
+ }
243
+
244
+ function defaultJobsDir() {
245
+ // Under the OS temp dir by default. Holds only the read-only /job inputs (prompt + .pi/); the
246
+ // workspace for a local job is the operator's own folder, not here.
247
+ return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/jobs`.replace(/\\/g, "/");
248
+ }
249
+
250
+ export function defaultSandboxDir(env = process.env) {
251
+ // Beside the per-job dirs, because a retained directory IS a per-job dir -- `cleanup` renames it here
252
+ // rather than copying, which only stays atomic while both live on one filesystem. Created mode 0700 by
253
+ // the retention step, since the OS temp dir is 1777 on POSIX and a retained tree holds a repository
254
+ // clone plus the run's prompt.md/event.json. Exported so the admin extension resolves the same default
255
+ // without calling loadConfig, which throws on unrelated env problems.
256
+ return `${env.PI_JOBS_DIR ?? defaultJobsDir()}/sandboxes`.replace(/\\/g, "/");
257
+ }
258
+
259
+ export function defaultLogsDir() {
260
+ // Under the OS temp dir by default. Holds durable per-run history/log artifacts written host-side;
261
+ // a worker-owned path that never enters the container env allowlist (no-broad-env-into-container).
262
+ return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
263
+ }
264
+
265
+ export function defaultSettingsFile() {
266
+ // Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
267
+ // extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env
268
+ // allowlist (no-broad-env-into-container). Exported so the admin extension resolves the same default
269
+ // without calling loadConfig, which throws on unrelated env problems.
270
+ return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/settings.json`.replace(/\\/g, "/");
271
+ }
272
+
273
+ /**
274
+ * The worker's Azure DevOps auth config, or `null` when none is configured -- same presence rule as the
275
+ * other two optional forges.
276
+ */
277
+ export function loadAzureAuth(env) {
278
+ const token = env.AZURE_TOKEN;
279
+ if (typeof token !== "string" || token.trim() === "") return null;
280
+ const source = env.AZURE_AUTH_SOURCE ?? "pat";
281
+ if (source !== "pat") {
282
+ throw configError(`AZURE_AUTH_SOURCE must be "pat" (got ${JSON.stringify(source)}) -- Azure DevOps has no App or installation-token equivalent, so there is no other source`);
283
+ }
284
+ const orgUrl = env.AZURE_ORG_URL;
285
+ if (typeof orgUrl !== "string" || orgUrl.trim() === "") {
286
+ throw configError("AZURE_ORG_URL is required when AZURE_TOKEN is set (e.g. https://dev.azure.com/your-org)");
287
+ }
288
+ return { source, orgUrl: orgUrl.trim().replace(/\/+$/, ""), tokenVar: "AZURE_TOKEN" };
289
+ }
290
+
291
+ /**
292
+ * The worker's Forgejo auth config, or `null` when none is configured -- same presence rule as GitLab's.
293
+ *
294
+ * `FORGEJO_BOT_ID` rides here because a repository-scoped Forgejo token cannot call `GET /user`, so the
295
+ * identity the bot-loop guard needs may have to be supplied rather than asked for (forgejo-identity.mjs).
296
+ */
297
+ export function loadForgejoAuth(env) {
298
+ const token = env.FORGEJO_TOKEN;
299
+ if (typeof token !== "string" || token.trim() === "") return null;
300
+ const source = env.FORGEJO_AUTH_SOURCE ?? "pat";
301
+ if (source !== "pat") {
302
+ throw configError(`FORGEJO_AUTH_SOURCE must be "pat" (got ${JSON.stringify(source)}) -- Forgejo has no App or installation-token equivalent, so there is no other source`);
303
+ }
304
+ const apiUrl = env.FORGEJO_URL;
305
+ // No default instance, deliberately: Forgejo is self-hosted by nature and there is no forgejo.com to
306
+ // fall back to. Guessing one would send an operator's token to a host they never named.
307
+ if (typeof apiUrl !== "string" || apiUrl.trim() === "") {
308
+ throw configError("FORGEJO_URL is required when FORGEJO_TOKEN is set -- there is no default Forgejo instance to fall back to");
309
+ }
310
+ return { source, apiUrl: apiUrl.trim(), tokenVar: "FORGEJO_TOKEN", botId: env.FORGEJO_BOT_ID ?? null };
311
+ }
312
+
313
+ /**
314
+ * The worker's GitLab auth config, or `null` when no GitLab is configured -- in which case the forge is
315
+ * simply absent from the map and a gitlab job refuses at mint time with a message naming what is missing.
316
+ *
317
+ * Only `pat` exists, and deliberately so: GitLab has no App equivalent, so there is no stronger source to
318
+ * offer and no choice to make. The variable is still named `GITLAB_AUTH_SOURCE` for symmetry with
319
+ * `GITHUB_AUTH_SOURCE`, so an operator reading .env.example finds the same shape on both sides.
320
+ */
321
+ export function loadGitLabAuth(env) {
322
+ const token = env.GITLAB_TOKEN;
323
+ if (typeof token !== "string" || token.trim() === "") return null;
324
+ const source = env.GITLAB_AUTH_SOURCE ?? "pat";
325
+ if (source !== "pat") {
326
+ throw configError(`GITLAB_AUTH_SOURCE must be "pat" (got ${JSON.stringify(source)}) -- GitLab has no App equivalent, so there is no other source`);
327
+ }
328
+ return { source, apiUrl: env.GITLAB_URL ?? "https://gitlab.com", tokenVar: "GITLAB_TOKEN" };
329
+ }
@@ -0,0 +1,40 @@
1
+ import { Redis } from "ioredis";
2
+
3
+ /**
4
+ * Connection helpers for BullMQ and the budget's raw Redis client, both from one VALKEY_URL.
5
+ *
6
+ * `maxRetriesPerRequest: null` is REQUIRED by BullMQ for its blocking connections (the Worker
7
+ * uses BRPOPLPUSH); without it BullMQ throws at construction. It is harmless on the Queue and the
8
+ * budget client, so it is set consistently.
9
+ */
10
+
11
+ /**
12
+ * BullMQ connection options parsed from a redis:// URL.
13
+ *
14
+ * `failFast` is for the CLI producer (a one-shot enqueue): if Valkey is unreachable it should
15
+ * error in a couple of seconds with a clear message, not hang forever. The long-running WORKER
16
+ * uses the default (persistent) options -- it should ride out a Valkey restart, not give up.
17
+ */
18
+ export function parseConnection(url, { failFast = false } = {}) {
19
+ const u = new URL(url);
20
+ return {
21
+ host: u.hostname || "127.0.0.1",
22
+ port: Number(u.port || 6379),
23
+ ...(u.password ? { password: u.password } : {}),
24
+ ...(u.username ? { username: u.username } : {}),
25
+ ...(u.pathname && u.pathname !== "/" ? { db: Number(u.pathname.slice(1)) } : {}),
26
+ maxRetriesPerRequest: null, // required for BullMQ blocking connections
27
+ ...(failFast
28
+ ? {
29
+ connectTimeout: 2000,
30
+ enableOfflineQueue: false, // don't buffer commands while disconnected -- error now
31
+ retryStrategy: (attempts) => (attempts > 2 ? null : 200), // give up after ~2 tries
32
+ }
33
+ : {}),
34
+ };
35
+ }
36
+
37
+ /** A raw ioredis client for the budget's INCR/EXPIRE. */
38
+ export function makeRedisClient(url) {
39
+ return new Redis(url, { maxRetriesPerRequest: null });
40
+ }
package/src/cron.mjs ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Reconcile the Redis-resident BullMQ job schedulers with the host-side schedule config
3
+ * (DES-CRON-VIA-BULLMQ-SCHEDULER). Given the normalized schedules from schedules.mjs, upsert each one
4
+ * and prune any resident scheduler the config no longer names. Idempotency is BullMQ's: upsert is keyed
5
+ * by schedulerId, so re-running with unchanged config installs the same set and removes nothing.
6
+ *
7
+ * The queue is injected, so this module carries no bullmq import and runs anywhere -- the whole
8
+ * reconcile is exercised in tier-1 tests against a fake queue, with no real Valkey.
9
+ *
10
+ * A `-10` (SchedulerJobIdCollision) or `-11` (SchedulerJobSlotsBusy) from upsertJobScheduler is a
11
+ * sentinel, not a success: swallowing it makes a schedule edit a silent no-op that looks identical to a
12
+ * clean install (design.md:231-232). Whether the SDK throws or returns the sentinel is unconfirmed until
13
+ * it runs against live Valkey, so BOTH the thrown path and the negative-return path are loud failures.
14
+ */
15
+
16
+ import { configError } from "./config.mjs";
17
+ import { loadSchedules } from "./schedules.mjs";
18
+
19
+ function sentinelName(code) {
20
+ if (code === -10) return "SchedulerJobIdCollision";
21
+ if (code === -11) return "SchedulerJobSlotsBusy";
22
+ return "unknown scheduler sentinel";
23
+ }
24
+
25
+ /**
26
+ * Install `schedules` (the normalized `{ schedulerId, name, pattern, data, opts }` shape from
27
+ * schedules.mjs) and prune orphans. `queue` supplies `upsertJobScheduler` / `getJobSchedulers` /
28
+ * `removeJobScheduler`; `log(event, fields)` records stable scheduler ids only. Returns
29
+ * `{ installed, removed }`.
30
+ */
31
+ export async function reconcile(queue, schedules, { log = () => {} } = {}) {
32
+ for (const { schedulerId, name, pattern, data, opts } of schedules) {
33
+ let res;
34
+ try {
35
+ // No custom jobId: BullMQ mints the deterministic repeat:<schedulerId>:<nextMillis> id itself.
36
+ res = await queue.upsertJobScheduler(schedulerId, { pattern }, { name, data, opts });
37
+ } catch (error) {
38
+ const loud = configError(`scheduler "${schedulerId}": upsertJobScheduler threw: ${error?.message ?? error}`);
39
+ loud.cause = error;
40
+ throw loud;
41
+ }
42
+
43
+ if (typeof res === "number" && res < 0) {
44
+ throw configError(
45
+ `scheduler "${schedulerId}": upsertJobScheduler returned ${res} (${sentinelName(res)}); a schedule edit would be a silent no-op`,
46
+ );
47
+ }
48
+ }
49
+
50
+ const resident = await queue.getJobSchedulers(0, -1, true);
51
+ const configIds = new Set(schedules.map((s) => s.schedulerId));
52
+
53
+ // Custom: trivial set-diff over getJobSchedulers; no package warranted
54
+ const orphanIds = resident
55
+ .map((d) => (typeof d === "string" ? d : (d.key ?? d.id ?? d.name)))
56
+ .filter((rid) => !configIds.has(rid));
57
+
58
+ for (const rid of orphanIds) {
59
+ try {
60
+ await queue.removeJobScheduler(rid);
61
+ } catch {
62
+ // A scheduler already gone (a concurrent reconcile pruned it) is the goal state, not an error.
63
+ }
64
+ log("scheduler_removed_orphan", { schedulerId: rid });
65
+ }
66
+
67
+ return { installed: schedules.length, removed: orphanIds.length };
68
+ }
69
+
70
+ /**
71
+ * Live-reload the cron schedulers from the (changed) triggers file: re-select the cron subset and reconcile
72
+ * it against the resident schedulers -- an add installs, a delete prunes (reconcile already removes orphans),
73
+ * an edit re-upserts. Idempotent, so a spurious watch event costs one no-op reconcile. A bad edit
74
+ * (`loadSchedules` throws a `configError`) is logged and the RUNNING schedulers are KEPT -- a live worker is
75
+ * never taken down by a malformed trigger file (the OQ-008 live-edit safety). Returns `{ ok }` /
76
+ * `{ invalid }` / `{ failed }`. `loadFn`/`reconcileFn` are injectable so the reload is unit-tested with no fs.
77
+ */
78
+ export async function reloadSchedules(config, queue, { log = () => {}, loadFn = loadSchedules, reconcileFn = reconcile } = {}) {
79
+ let schedules;
80
+ try {
81
+ schedules = loadFn(config);
82
+ } catch (error) {
83
+ log("schedules_reload_invalid", { reason: error?.message ?? String(error), kept: true });
84
+ return { invalid: error?.message ?? String(error) };
85
+ }
86
+ try {
87
+ const r = await reconcileFn(queue, schedules, { log });
88
+ log("schedules_reloaded", { installed: r.installed, removed: r.removed });
89
+ return { ok: true, ...r };
90
+ } catch (error) {
91
+ log("schedules_reload_failed", { reason: error?.message ?? String(error) });
92
+ return { failed: error?.message ?? String(error) };
93
+ }
94
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * INT-CONTAINER-RUNTIME-CONTRACT. Construct the `docker run` argv for one job container.
3
+ *
4
+ * Every flag here is the enforcement surface of CONST-ISOLATION-CONTAINER-PER-JOB -- pi has no
5
+ * permission system, so the container is the only real control. The argv is built as an explicit
6
+ * array (never a shell string): no interpolation, no injection, and the env allowlist is passed
7
+ * with explicit `-e NAME` where the value is read from the argv env map, never `--env-file` and
8
+ * never a host pass-through.
9
+ */
10
+
11
+ /**
12
+ * Where the operator's global pi overlay lands INSIDE the container (REQ-GLOBAL-PI-OVERLAY). Exported
13
+ * because packages.mjs derives the staged-packages root from it: the mount and that root are ONE fact on
14
+ * one side of the boundary, and two literals in two modules could drift apart with both test suites still
15
+ * green. A Linux container path, so it is always built with "/" -- never `path.join`, which yields
16
+ * backslashes when the worker itself runs on Windows.
17
+ */
18
+ export const CONTAINER_GLOBAL_PI_DIR = "/opt/pi-global";
19
+
20
+ /**
21
+ * The session mount and the file inside it, exported together and used by both the argv builder here and
22
+ * the env builder in env-allowlist.mjs. Two literals in two modules is how a mount and the variable
23
+ * naming a path inside it drift apart with both suites green -- the runner would then look for a
24
+ * transcript at a path nothing mounted, find none, and cold-start every job without saying so.
25
+ *
26
+ * Nothing key-derived crosses the boundary: the container always sees the same constant path, so no
27
+ * repository name, no branch name and no host layout is legible from inside a job.
28
+ */
29
+ export const CONTAINER_SESSION_DIR = "/session";
30
+ export const CONTAINER_SESSION_FILE = `${CONTAINER_SESSION_DIR}/current.jsonl`;
31
+
32
+ /** The fixed isolation flags. Not configurable -- these ARE the boundary. */
33
+ export const ISOLATION_FLAGS = [
34
+ // The image must ALREADY be on this host. `docker run` defaults to --pull=missing, so an unknown name is
35
+ // a registry FETCH: a typo in the operator's image config would otherwise pull and execute a stranger's
36
+ // image under a name that looks like theirs. Every other flag here bounds what a chosen image may DO;
37
+ // this one bounds which image is chosen at all, which is why it leads. The same make-it-unreachable move
38
+ // PI_OFFLINE=1 makes one layer up, and it costs nothing the documented flow was using: the README's
39
+ // install step is an explicit `docker pull && docker tag`, and `pi-job:latest` is a local-only tag with
40
+ // no registry behind it. A readable diagnosis is the preflight's job (image-preflight.mjs); this is the
41
+ // part that cannot be raced.
42
+ "--pull=never",
43
+ "--rm", // ephemeral: gone after the run
44
+ "--init", // reap zombies (Chromium spawns many); node is PID 1 and does not reap
45
+ "--cap-drop=ALL", // pi would otherwise inherit the launching user's capabilities
46
+ "--security-opt",
47
+ "no-new-privileges",
48
+ "--pids-limit=512", // bound a fork bomb (UNVERIFIED figure; measured headroom ~4.5x, see spec)
49
+ "--shm-size=1g", // Chromium OOMs on the default 64MB /dev/shm; NOT --ipc=host (shares host ns)
50
+ ];
51
+
52
+ /**
53
+ * Build the full `docker run` argv (excluding the leading "docker").
54
+ *
55
+ * @param image pinned job image tag/digest
56
+ * @param env the closed env map from buildContainerEnv -- passed as explicit -e NAME=VALUE
57
+ * @param jobDir host path to the /job inputs dir (contains prompt.md and pi/); mounted /job:ro
58
+ * @param workspace host path to the fresh clone / local folder (mounted /workspace:rw)
59
+ * @param outboxDir host path to the /outbox chain-request dir (local jobs only); mounted /outbox:rw
60
+ * @param sessionDir host path to this job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION);
61
+ * mounted /session:rw. Per-job, like jobDir -- never the shared store.
62
+ * @param globalPiDir host path to the operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY); mounted /opt/pi-global:ro
63
+ * @param name container name (for `docker stop` at the timeout)
64
+ * @param memory e.g. "4g"; cpus e.g. "2"
65
+ * @param extraFlags escape hatch for a Linux-only --user uid:gid on a bind-mounted local folder
66
+ */
67
+ export function buildDockerRunArgs({
68
+ image,
69
+ env,
70
+ jobDir,
71
+ workspace,
72
+ outboxDir,
73
+ sessionDir,
74
+ globalPiDir,
75
+ name,
76
+ memory = "4g",
77
+ cpus = "2",
78
+ extraFlags = [],
79
+ }) {
80
+ if (!image) throw new Error("docker run: image is required");
81
+ if (!name) throw new Error("docker run: container name is required");
82
+ if (!workspace) throw new Error("docker run: workspace mount is required");
83
+
84
+ const args = ["run", `--name=${name}`, ...ISOLATION_FLAGS, `--memory=${memory}`, `--cpus=${cpus}`, ...extraFlags];
85
+
86
+ // Explicit env allowlist. Each entry is `-e NAME=VALUE`, built from the closed map -- so a
87
+ // stray host variable cannot ride along (no bare `-e NAME` inheriting from the host, no
88
+ // --env-file). Undefined values are skipped, never passed as an empty string.
89
+ for (const [k, v] of Object.entries(env ?? {})) {
90
+ if (v === undefined || v === null) continue;
91
+ args.push("-e", `${k}=${v}`);
92
+ }
93
+
94
+ // The WHOLE /job dir is read-only (INT-CONTAINER-JOB-INPUTS): it holds prompt.md and pi/, and
95
+ // the agent cannot rewrite any of it. /workspace is the only writable mount.
96
+ if (jobDir) args.push("-v", `${jobDir}:/job:ro`);
97
+ args.push("-v", `${workspace}:/workspace`);
98
+ // Local jobs get a writable /outbox host bind, the same host-bind mechanism as /workspace
99
+ // (DES-WORKER-ON-HOST). github jobs pass no outboxDir, so the request channel does not exist for
100
+ // them -- an untrusted issue author cannot chain (INT-OUTBOX-CONTRACT).
101
+ if (outboxDir) args.push("-v", `${outboxDir}:/outbox`);
102
+
103
+ // This job's OWN copy of its session transcript (REQ-RESUMABLE-SESSION, INT-SESSION-STORE-CONTRACT).
104
+ // Writable, because pi appends to it as the agent works -- and per-job, exactly like jobDir, which is
105
+ // the whole reason CONST-ISOLATION-CONTAINER-PER-JOB's "none host-wide" clause still reads true. The
106
+ // shared store under PI_SESSIONS_DIR is NEVER bind-mounted: one job here would otherwise be able to
107
+ // read and rewrite every other branch's and every other repository's transcripts, which is not a
108
+ // weakening of that constraint but its inversion. Absent unless the trigger armed run.resume AND a key
109
+ // resolved, so an unarmed job's argv is byte-identical to one built before this feature existed.
110
+ if (sessionDir) args.push("-v", `${sessionDir}:${CONTAINER_SESSION_DIR}`);
111
+
112
+ // The operator's global pi overlay (REQ-GLOBAL-PI-OVERLAY): custom models, global skills, a global
113
+ // persona, layered UNDER each repo's own .pi/. Read-only -- it is operator-authored deploy-time config,
114
+ // the same trust class as the baked floor, but the agent still must not rewrite it. Both job kinds.
115
+ if (globalPiDir) args.push("-v", `${globalPiDir}:${CONTAINER_GLOBAL_PI_DIR}:ro`);
116
+
117
+ args.push(image);
118
+ return args;
119
+ }