@edgehero/pi-dispatch 2.1.0 → 3.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 (72) hide show
  1. package/.env.example +41 -5
  2. package/README.md +11 -5
  3. package/deploy/docker-compose.yml +12 -0
  4. package/deploy/egress-proxy.conf +28 -3
  5. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  6. package/package.json +9 -2
  7. package/src/allocation.mjs +731 -0
  8. package/src/backends.mjs +243 -0
  9. package/src/budget.mjs +40 -4
  10. package/src/cli.mjs +222 -11
  11. package/src/config.mjs +126 -5
  12. package/src/daemon-facts.mjs +3 -0
  13. package/src/deployment-venue.mjs +1 -0
  14. package/src/doctor.mjs +2280 -195
  15. package/src/dollar-budget.mjs +373 -0
  16. package/src/dollar-fingerprint.mjs +83 -0
  17. package/src/egress-cli.mjs +316 -0
  18. package/src/egress-proxy-state.mjs +35 -5
  19. package/src/egress.mjs +12 -0
  20. package/src/env-allowlist.mjs +125 -6
  21. package/src/env-file.mjs +194 -25
  22. package/src/envelope.mjs +413 -0
  23. package/src/exit-code.mjs +22 -0
  24. package/src/fleet-lease.mjs +85 -25
  25. package/src/get-token.mjs +16 -5
  26. package/src/git-dirty.mjs +67 -0
  27. package/src/github-app-setup.mjs +6 -3
  28. package/src/github-host.mjs +5 -3
  29. package/src/host-pi.mjs +1 -1
  30. package/src/identity.mjs +2 -1
  31. package/src/image-preflight.mjs +98 -24
  32. package/src/image-ref.mjs +37 -0
  33. package/src/import-pi.mjs +4 -2
  34. package/src/index.mjs +407 -62
  35. package/src/init.mjs +18 -0
  36. package/src/job-id.mjs +26 -3
  37. package/src/live-probes.mjs +24 -9
  38. package/src/model-catalog.mjs +297 -0
  39. package/src/model-endpoints.mjs +671 -0
  40. package/src/model-ref.mjs +151 -0
  41. package/src/models-json.mjs +268 -0
  42. package/src/money.mjs +144 -0
  43. package/src/octokit-log.mjs +65 -0
  44. package/src/outbox-plan.mjs +218 -0
  45. package/src/outbox.mjs +29 -9
  46. package/src/output-cap.mjs +157 -0
  47. package/src/pause-windows.mjs +81 -2
  48. package/src/pi-model-loader.mjs +77 -0
  49. package/src/podman-stack.mjs +16 -3
  50. package/src/portfolio-snapshot.mjs +304 -0
  51. package/src/prepare-local.mjs +247 -12
  52. package/src/prepare.mjs +35 -3
  53. package/src/priorities.mjs +569 -0
  54. package/src/processor.mjs +599 -170
  55. package/src/project-id.mjs +17 -0
  56. package/src/projects.mjs +238 -0
  57. package/src/provider-steering.mjs +179 -65
  58. package/src/queue.mjs +111 -6
  59. package/src/reserved-env.mjs +31 -0
  60. package/src/run-container.mjs +59 -5
  61. package/src/run-history.mjs +379 -24
  62. package/src/run-mirror.mjs +30 -0
  63. package/src/runtime-settings.mjs +104 -9
  64. package/src/schedules.mjs +33 -1
  65. package/src/scoped-limits.mjs +447 -27
  66. package/src/service.mjs +15 -4
  67. package/src/session-store.mjs +131 -6
  68. package/src/start.mjs +528 -40
  69. package/src/triggers-file.mjs +65 -4
  70. package/src/triggers.mjs +141 -11
  71. package/src/up.mjs +308 -34
  72. package/src/valkey-endpoint.mjs +3 -2
@@ -26,6 +26,7 @@ import { execFileSync } from "node:child_process";
26
26
  import { connect as netConnect } from "node:net";
27
27
  import { dirname, join } from "node:path";
28
28
  import { DEFAULT_EGRESS_PROXY, egressProxyName } from "./egress.mjs";
29
+ import { MODEL_ENDPOINTS_INCLUDE_NAME } from "./model-endpoints.mjs";
29
30
  import { VALKEY_PASSWORD_KEY, valkeyEnvFileText } from "./valkey-auth.mjs";
30
31
  import { SYSTEMD_HAZARD_SHAPES, decodeEnvFile, envFileHazard, envFileSystemdHazard, envFileValueLines, invisibleCharacter, quotedRegions, readEnvAssignments } from "./env-file.mjs";
31
32
  import { NETNS_KEEPER, NETNS_KEEPER_FORMAT, NETNS_KEEPER_NOW_FORMAT, STARTED_AT_FORMAT, NETNS_KEEPER_MIN_AGE_MS, NETNS_KEEPER_AFTER_PROXY_GRACE_MS, judgeNetnsKeeper, podmanNeedsNetnsKeeper, makeDetachGate, detachBlockedSentence, DETACH_GATE_READ_TIMEOUT_MS, DETACH_GATE_READ_MAX_BUFFER, runtimeFromFacts } from "./netns-keeper.mjs";
@@ -89,9 +90,17 @@ export const QUADLET_FILES = Object.freeze({
89
90
  /** Every Quadlet file this project ships, for uninstall and status, which act on what exists rather than on a plan. */
90
91
  export const ALL_QUADLET_FILES = Object.freeze(Object.values(QUADLET_FILES));
91
92
 
92
- /** The two placeholders the proxy's template carries, and what each becomes (TEMPLATE_PINS in service.mjs pins both). */
93
+ /** The three placeholders the proxy's template carries, and what each becomes (TEMPLATE_PINS in service.mjs pins them). */
93
94
  export const PROXY_CONF_PLACEHOLDER = "/opt/pi-dispatch/deploy/egress-proxy.conf";
94
95
  export const ALLOWLIST_PLACEHOLDER = "/opt/pi-dispatch/egress-allowlist.conf";
96
+ /**
97
+ * The declared model endpoints' rules (issue #503): the deployment folder's own `model-endpoints.conf`, mounted
98
+ * directly as the allowlist is, never copied like the rules are. `pi-dispatch egress render` rewrites that file in
99
+ * place and the proxy's reload reads the mounted inode, so a copy here would be a file the render never wrote. The
100
+ * rules are copied only because `z` cannot relabel a root-owned package file; this one is the operator's, as the
101
+ * allowlist is.
102
+ */
103
+ export const MODEL_ENDPOINTS_PLACEHOLDER = "/opt/pi-dispatch/model-endpoints.conf";
95
104
 
96
105
  /**
97
106
  * Where the proxy's RULES are mounted from: an account-owned COPY of the package's `egress-proxy.conf`, never the
@@ -691,8 +700,9 @@ export function planStack({ components, templatesDir, deployDir, home, fs, readT
691
700
  if (components.keeper) picked.push(QUADLET_FILES.keeperNetwork, QUADLET_FILES.keeper);
692
701
  const conf = proxyConfCopyPath(home);
693
702
  const allowlist = join(deployDir, "egress-allowlist.conf");
703
+ const endpointsInclude = join(deployDir, MODEL_ENDPOINTS_INCLUDE_NAME);
694
704
  if (components.proxy) {
695
- for (const p of [conf, allowlist]) {
705
+ for (const p of [conf, allowlist, endpointsInclude]) {
696
706
  if (UNSAFE_VOLUME_PATH.test(p)) {
697
707
  return { error: `the egress proxy's Quadlet unit would mount ${JSON.stringify(p)}, and a Quadlet Volume= cannot carry a colon, whitespace, %, $, a quote, a backslash or a control byte in a path (each is split or expanded on the way to podman run). Move the deployment folder, or start the proxy by hand (docs/podman.md)` };
698
708
  }
@@ -702,7 +712,10 @@ export function planStack({ components, templatesDir, deployDir, home, fs, readT
702
712
  let text = String(readTemplate(file));
703
713
  if (file === QUADLET_FILES.proxy.file) {
704
714
  // Function replacements: a computed path is the REPLACEMENT, and String.replace reads `$&` out of a string one.
705
- text = text.replace(`Volume=${PROXY_CONF_PLACEHOLDER}:`, () => `Volume=${conf}:`).replace(`Volume=${ALLOWLIST_PLACEHOLDER}:`, () => `Volume=${allowlist}:`);
715
+ text = text
716
+ .replace(`Volume=${PROXY_CONF_PLACEHOLDER}:`, () => `Volume=${conf}:`)
717
+ .replace(`Volume=${ALLOWLIST_PLACEHOLDER}:`, () => `Volume=${allowlist}:`)
718
+ .replace(`Volume=${MODEL_ENDPOINTS_PLACEHOLDER}:`, () => `Volume=${endpointsInclude}:`);
706
719
  }
707
720
  if (file === QUADLET_FILES.valkey.file && Number.isInteger(components.valkeyPort) && components.valkeyPort !== DEFAULT_VALKEY_PORT) {
708
721
  // Issue #464: VALKEY_URL's loopback port, where this account's own Valkey is published (the container still
@@ -0,0 +1,304 @@
1
+ /**
2
+ * The portfolio snapshot, `/job/portfolio.json` (issue #505, INT-CONTAINER-JOB-INPUTS): what a flagged cron job's agent
3
+ * reads to write a priorities plan (INT-PRIORITIES-PLAN-CONTRACT). A job container is queue-blind on purpose
4
+ * (DES-JOB-OUTBOX-CHAINING), so the facts a project manager needs are written into its read-only `/job` instead: the
5
+ * envelope's numbers, the applied split, what each project has spent in the window, and how its runs went.
6
+ *
7
+ * THE CONTENT RULE, and why it is strict. The agent reads this file as facts. So it holds ids, digests, integers, ISO
8
+ * instants and fixed enum tokens only, plus two kinds of operator configuration: project ids and a member's label
9
+ * (`github:acme/web` as projects.json stores it, or `local:<basename>` for a folder, the `targetFor` rule), and a label
10
+ * holding a control, format, bidi or unassigned character is replaced by the member's ref. It holds no
11
+ * issue text, no title, no task, no plan `reason` and no path: each is text an agent or a forge user wrote, and the next
12
+ * manager run would read it as if it were a fact. Every value is rebuilt here from named fields, charset-checked, and
13
+ * nothing is spread from a record, a state or a row.
14
+ *
15
+ * Money is integer micro-dollars, from the FLEET-WIDE counters (the `budget:usd:s:*` keys every host reserves and
16
+ * settles in), so the numbers are complete on every host. A counter holds what was spent AND what running jobs still
17
+ * hold: that is the number the next job is admitted against, so it is the one a manager should plan with. Run counts
18
+ * come from the local run history merged with the Valkey run mirror; without a mirror (no `PI_WORKER_NAME`) they are
19
+ * this host's alone, and `fleet.runsComplete` says so.
20
+ */
21
+
22
+ import * as nodeFs from "node:fs";
23
+ import { basename, isAbsolute, join } from "node:path";
24
+ import { AUDIT_OUTCOMES, STATE_WRITERS, memberDollarKeyPrefix } from "./allocation.mjs";
25
+ import { dayKey, monthKey, weekKey } from "./budget.mjs";
26
+ import { OTHER, PLAN_FIELDS, PLAN_RULES, envelopeEntries, scopeRef } from "./priorities.mjs";
27
+ import { InfraRetry } from "./processor.mjs";
28
+ import { isProjectId } from "./project-id.mjs";
29
+ import { RUNS_INDEX_MAX, mergeRuns, readMirroredRuns } from "./run-mirror.mjs";
30
+ import { scopeDollarKeyPrefix } from "./scoped-limits.mjs";
31
+
32
+ /** The snapshot's shape version. */
33
+ export const PORTFOLIO_SNAPSHOT_VERSION = 1;
34
+ /** The largest snapshot written, in bytes. Over it the job is refused before it spends. */
35
+ export const PORTFOLIO_SNAPSHOT_MAX_BYTES = 64 * 1024;
36
+ /** The refusal of a job whose snapshot would be larger than `PORTFOLIO_SNAPSHOT_MAX_BYTES`. */
37
+ export const PORTFOLIO_SNAPSHOT_OVERSIZE = "portfolio-snapshot-oversize";
38
+ /** How far back `runs7d` counts. */
39
+ export const RUNS_WINDOW_DAYS = 7;
40
+ /** The run outcomes `runs7d` counts. */
41
+ export const RUN_OUTCOMES = Object.freeze(["completed", "policy", "failed"]);
42
+ /** The `byReason` key a reason that is not a plain token is counted under: the token rule, never the text. */
43
+ export const OTHER_REASON = "other";
44
+
45
+ const DAY_MS = 24 * 60 * 60 * 1000;
46
+ const TOKEN_RE = /^[a-z][a-z0-9-]{0,63}$/;
47
+ const HEX16_RE = /^[0-9a-f]{16}$/;
48
+ const INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
49
+ // What a label may not hold: every control, format character (the bidi controls included), surrogate, private-use or
50
+ // unassigned code point, and the line and paragraph separators. Each can change what a reader sees.
51
+ const LABEL_REFUSED = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/u;
52
+
53
+ const asDate = (now) => (now instanceof Date ? now : new Date(now));
54
+ const instant = (value) => (typeof value === "string" && INSTANT_RE.test(value) && Number.isFinite(Date.parse(value)) ? value : null);
55
+ const micros = (value) => (Number.isSafeInteger(value) && value >= 0 ? value : null);
56
+ const ymd = (ms) => new Date(ms).toISOString().slice(0, 10);
57
+
58
+ /** The envelope window that holds `now`: `{ kind, start, end }`, `end` the first day after it (UTC dates). */
59
+ export function windowBounds(kind, now) {
60
+ const d = asDate(now);
61
+ const day = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
62
+ if (kind === "day") return { kind, start: ymd(day), end: ymd(day + DAY_MS) };
63
+ if (kind === "week") {
64
+ const monday = day - ((new Date(day).getUTCDay() + 6) % 7) * DAY_MS;
65
+ return { kind, start: ymd(monday), end: ymd(monday + 7 * DAY_MS) };
66
+ }
67
+ if (kind === "month") return { kind, start: ymd(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)), end: ymd(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)) };
68
+ throw new TypeError(`unknown envelope window: ${kind}`);
69
+ }
70
+
71
+ /** A counter key of `prefix` for the envelope window holding `now`, by budget.mjs's own key functions. */
72
+ function windowKey(prefix, kind, now) {
73
+ return kind === "day" ? dayKey(now, prefix) : kind === "week" ? weekKey(now, prefix) : monthKey(now, prefix);
74
+ }
75
+
76
+ /** A counter's value as micro-dollars: absent is 0 (nothing reserved yet), anything that is no integer is 0. */
77
+ function counterMicros(raw) {
78
+ if (raw === null || raw === undefined) return 0;
79
+ const n = Number(raw);
80
+ return Number.isSafeInteger(n) && n > 0 ? n : 0;
81
+ }
82
+
83
+ /**
84
+ * A member's label: a forge member as projects.json stores it, a folder as `local:<basename>` (never the path). A label
85
+ * holding a character `LABEL_REFUSED` names, or longer than 200 code points, is the member's ref instead.
86
+ */
87
+ export function memberLabel(member) {
88
+ const label = isAbsolute(member) ? `local:${basename(member)}` : member;
89
+ return LABEL_REFUSED.test(label) || [...label].length > 200 ? scopeRef(member) : label;
90
+ }
91
+
92
+ /** An empty `runs7d`. */
93
+ function emptyRuns() {
94
+ return { completed: 0, policy: 0, failed: 0, byReason: {} };
95
+ }
96
+
97
+ /** `byReason` with its keys sorted, so the bytes do not depend on the order runs were read in. */
98
+ function sortedCounts(counts) {
99
+ return Object.fromEntries(Object.keys(counts).sort().map((k) => [k, counts[k]]));
100
+ }
101
+
102
+ /**
103
+ * Count runs by envelope entry: a record's `project` when the envelope names it, else `_other`. Only the three outcomes
104
+ * and a reason that is a plain token are read; a reason that is not one counts under `other`, so no text a record could
105
+ * carry (a planted title included) reaches the snapshot.
106
+ */
107
+ function countRuns(records, entries, sinceMs) {
108
+ const out = Object.fromEntries(entries.map((id) => [id, emptyRuns()]));
109
+ for (const r of records) {
110
+ const at = Date.parse(r?.endedAt ?? r?.startedAt ?? "");
111
+ if (!Number.isFinite(at) || at < sinceMs) continue;
112
+ if (!RUN_OUTCOMES.includes(r?.outcome)) continue;
113
+ const id = isProjectId(r?.project) && r.project !== OTHER && entries.includes(r.project) ? r.project : OTHER;
114
+ const row = out[id];
115
+ row[r.outcome] += 1;
116
+ if (r.reason !== null && r.reason !== undefined) {
117
+ const key = typeof r.reason === "string" && TOKEN_RE.test(r.reason) ? r.reason : OTHER_REASON;
118
+ row.byReason[key] = (row.byReason[key] ?? 0) + 1;
119
+ }
120
+ }
121
+ for (const id of entries) out[id].byReason = sortedCounts(out[id].byReason);
122
+ return out;
123
+ }
124
+
125
+ /** The applied plan as the snapshot shows it, or null for the neutral split (then a plan's `basis` is null). */
126
+ function planOf(state) {
127
+ if (typeof state?.planId !== "string" || !HEX16_RE.test(state.planId)) return null;
128
+ return {
129
+ id: state.planId,
130
+ writer: STATE_WRITERS.includes(state.writer) ? state.writer : null,
131
+ appliedAt: instant(state.appliedAt),
132
+ validUntil: instant(state.validUntil),
133
+ clamped: state.clamped === true,
134
+ };
135
+ }
136
+
137
+ /** The last attempt of this trigger's portfolio jobs, from an `alloc:log` row: enum tokens and an id only. */
138
+ function lastAttemptOf(row) {
139
+ if (!row || typeof row !== "object") return null;
140
+ const out = {
141
+ at: instant(row.at),
142
+ outcome: AUDIT_OUTCOMES.includes(row.outcome) ? row.outcome : null,
143
+ reason: typeof row.reason === "string" && TOKEN_RE.test(row.reason) ? row.reason : null,
144
+ planId: typeof row.planId === "string" && HEX16_RE.test(row.planId) ? row.planId : null,
145
+ };
146
+ if (PLAN_FIELDS.includes(row.field)) {
147
+ out.field = row.field;
148
+ out.rule = PLAN_RULES.includes(row.rule) ? row.rule : null;
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** When the next plan may apply: the last plan plus the interval, or null when no writer's plan has applied. */
154
+ function planAllowedAfter(state, envelope) {
155
+ const last = Date.parse(state?.lastPlanAt ?? "");
156
+ const hours = envelope?.delegation?.minIntervalHours;
157
+ if (!Number.isFinite(last) || !Number.isInteger(hours)) return null;
158
+ return new Date(last + hours * 60 * 60 * 1000).toISOString();
159
+ }
160
+
161
+ /**
162
+ * Build the snapshot object. `envelope` and `digest` are this host's (envelope.mjs), `projects` the parsed projects.json,
163
+ * `limits` the parsed scoped-limits.json (a member's spend is read from the key its matched row settles in,
164
+ * `memberDollarKeyPrefix`, the one enforcement uses),
165
+ * `allocation` `{ state, lastAttempt }` (the applied state `alloc:plan` and the newest `alloc:log` row of this trigger's
166
+ * portfolio jobs), `redis` the client the counters are read from (one MGET), `runs` `{ records, complete }` (the merged
167
+ * history) and `now` the clock. Throws on a Valkey fault; the caller turns that into an InfraRetry.
168
+ */
169
+ export async function buildPortfolioSnapshot({ envelope, digest, projects = [], limits = null, allocation = {}, redis, runs = { records: [], complete: false }, now }) {
170
+ const at = asDate(now);
171
+ const state = allocation.state ?? null;
172
+ const kind = envelope.window;
173
+ const entries = envelopeEntries(envelope);
174
+ const byId = new Map((Array.isArray(projects) ? projects : []).filter((p) => isProjectId(p?.id)).map((p) => [p.id, p]));
175
+
176
+ // Every counter in ONE read: each entry's project key, then each member that has a repo share.
177
+ const keys = [];
178
+ const rows = entries.map((id) => {
179
+ const projectKey = keys.push(windowKey(scopeDollarKeyPrefix(`project:${id}`), kind, at)) - 1;
180
+ const members = id === OTHER ? [] : (byId.get(id)?.members ?? []).filter((m) => typeof m === "string" && m !== "");
181
+ const memberRows = members
182
+ .map((m) => {
183
+ const ref = scopeRef(m);
184
+ const weight = state?.repoWeights?.[id]?.[ref];
185
+ return { ref, label: memberLabel(m), weight: Number.isInteger(weight) ? weight : null, allocation: micros(state?.repos?.[id]?.[ref]), key: Number.isInteger(weight) ? keys.push(windowKey(memberDollarKeyPrefix(m, { limits }), kind, at)) - 1 : null };
186
+ })
187
+ .sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
188
+ return { id, projectKey, memberRows };
189
+ });
190
+ const values = keys.length > 0 ? await redis.mget(...keys) : [];
191
+ const counted = countRuns(Array.isArray(runs.records) ? runs.records : [], entries, at.getTime() - RUNS_WINDOW_DAYS * DAY_MS);
192
+
193
+ return {
194
+ version: PORTFOLIO_SNAPSHOT_VERSION,
195
+ generatedAt: at.toISOString(),
196
+ window: windowBounds(kind, at),
197
+ envelope: {
198
+ digest: typeof digest === "string" && HEX16_RE.test(digest) ? digest : null,
199
+ totalMicros: envelope.totalMicros,
200
+ maxStepPct: envelope.delegation?.maxStepPct ?? null,
201
+ minIntervalHours: envelope.delegation?.minIntervalHours ?? null,
202
+ maxPlanDays: envelope.delegation?.maxPlanDays ?? null,
203
+ planAllowedAfter: planAllowedAfter(state, envelope),
204
+ },
205
+ plan: planOf(state),
206
+ lastAttempt: lastAttemptOf(allocation.lastAttempt),
207
+ projects: rows.map(({ id, projectKey, memberRows }) => ({
208
+ id,
209
+ floorMicros: envelope.floors[id],
210
+ weight: Number.isInteger(state?.weights?.[id]) ? state.weights[id] : envelope.defaultWeights[id],
211
+ allocationMicros: micros(state?.allocations?.[id]),
212
+ spentMicros: counterMicros(values[projectKey]),
213
+ members: memberRows.map((m) => ({ ref: m.ref, label: m.label, ...(m.weight === null ? {} : { weight: m.weight, allocationMicros: m.allocation, spentMicros: counterMicros(values[m.key]) }) })),
214
+ runs7d: counted[id],
215
+ })),
216
+ fleet: { runsComplete: runs.complete === true },
217
+ };
218
+ }
219
+
220
+ /**
221
+ * The run records of this host's history (`PI_LOGS_DIR/*.json`) that ended at or after `sinceMs`: `{ records, complete }`.
222
+ * A file older than the window by its mtime is not opened (a record is written when its run ends). Never throws: a
223
+ * missing directory is no runs, and any other fault reads as `complete: false`.
224
+ */
225
+ export function readLocalRuns({ logsDir, sinceMs, fs = nodeFs }) {
226
+ let names;
227
+ try {
228
+ names = fs.readdirSync(logsDir);
229
+ } catch (error) {
230
+ return { records: [], complete: error?.code === "ENOENT" };
231
+ }
232
+ const records = [];
233
+ let complete = true;
234
+ for (const name of names) {
235
+ if (!name.endsWith(".json")) continue;
236
+ try {
237
+ const st = fs.statSync(join(logsDir, name));
238
+ if (!st.isFile() || st.mtimeMs < sinceMs) continue;
239
+ const rec = JSON.parse(fs.readFileSync(join(logsDir, name), "utf8"));
240
+ if (rec && typeof rec === "object" && typeof rec.jobId === "string") records.push(rec);
241
+ } catch (error) {
242
+ // A record reaped between the listing and the read is simply gone; anything else leaves the count short.
243
+ if (error?.code !== "ENOENT") complete = false;
244
+ }
245
+ }
246
+ return { records, complete };
247
+ }
248
+
249
+ /**
250
+ * The prepare-time builder the wiring hands `prepareLocalWorkspace` (through `makePrepareWorkspace`), as
251
+ * `(job) => null | { body } | { outcome: "policy", reason }`. Called only for a job the processor confirmed as a
252
+ * portfolio job at pickup, and it confirms again here: the LIVE triggers file must still flag the job's entry
253
+ * (`checkPortfolioFlag`), else nothing is written and null comes back. Null too when this host has no envelope any more:
254
+ * the plan would be refused as `delegation-off`, so there is nothing true to show.
255
+ *
256
+ * Every Valkey read (the reconcile, `alloc:log`, the counters) that fails throws InfraRetry: the job has not reserved
257
+ * anything yet, so it is retried rather than run on a snapshot with holes. The run mirror is a view and never throws; a
258
+ * mirror that could not be read makes `runsComplete` false. A snapshot over 64 KiB is refused as
259
+ * `portfolio-snapshot-oversize`, before any spend, and the log line names the project count.
260
+ */
261
+ export function makePortfolioSnapshot({ checkPortfolioFlag, governing, projects = () => [], limits = () => null, allocation, redis, logsDir, mirror = false, readMirror = readMirroredRuns, fs = nodeFs, now = () => new Date(), log = () => {} }) {
262
+ return async function portfolioSnapshot(job) {
263
+ const flagged = await Promise.resolve()
264
+ .then(() => checkPortfolioFlag(job))
265
+ .catch(() => false);
266
+ if (flagged !== true) {
267
+ log("portfolio_snapshot_skipped", { why: "flag-not-live" });
268
+ return null;
269
+ }
270
+ const g = governing?.() ?? null;
271
+ if (!g?.envelope) {
272
+ log("portfolio_snapshot_skipped", { why: "no-envelope" });
273
+ return null;
274
+ }
275
+ const at = now();
276
+ const sinceMs = at.getTime() - RUNS_WINDOW_DAYS * DAY_MS;
277
+ let snapshot;
278
+ try {
279
+ const { state } = await allocation.reconcile({ envelope: g.envelope, digest: g.digest, now: at });
280
+ if (!state) throw new Error("the applied state was written by a newer build");
281
+ const last = await allocation.lastAttempt({ kind: "portfolio-job", triggerId: job?.trigger?.id });
282
+ const local = readLocalRuns({ logsDir, sinceMs, fs });
283
+ let mirrored = [];
284
+ let complete = false;
285
+ if (mirror) {
286
+ const read = await readMirror(redis, { limit: RUNS_INDEX_MAX, sinceMs, now: () => at.getTime() });
287
+ mirrored = read.runs;
288
+ complete = local.complete && (read.degraded === "ok" || read.degraded === "off");
289
+ }
290
+ const records = mergeRuns(local.records, mirrored, { limit: Number.POSITIVE_INFINITY });
291
+ snapshot = await buildPortfolioSnapshot({ envelope: g.envelope, digest: g.digest, projects: projects(), limits: limits(), allocation: { state, lastAttempt: last }, redis, runs: { records, complete }, now: at });
292
+ } catch (error) {
293
+ log("portfolio_snapshot_failed", { code: typeof error?.code === "string" ? error.code : "error" });
294
+ throw new InfraRetry("the portfolio snapshot could not be read", { cause: error });
295
+ }
296
+ const body = JSON.stringify(snapshot, null, 2);
297
+ const bytes = Buffer.byteLength(body, "utf8");
298
+ if (bytes > PORTFOLIO_SNAPSHOT_MAX_BYTES) {
299
+ log("refused_portfolio_snapshot_oversize", { projects: snapshot.projects.length, bytes, max: PORTFOLIO_SNAPSHOT_MAX_BYTES });
300
+ return { outcome: "policy", reason: PORTFOLIO_SNAPSHOT_OVERSIZE };
301
+ }
302
+ return { body };
303
+ };
304
+ }
@@ -1,7 +1,8 @@
1
1
  import { GIT_READ_FLAGS } from "./git-hardening.mjs";
2
2
  import { execFile } from "node:child_process";
3
3
  import * as realFs from "node:fs";
4
- import { basename, join } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
5
+ import { envelopeProtectedIdentities, fileIdentity } from "./envelope.mjs";
5
6
  import { promisify } from "node:util";
6
7
  import { materializePiDir } from "./materialize.mjs";
7
8
  import { InfraRetry } from "./processor.mjs";
@@ -9,6 +10,22 @@ import { isDeterminateFsCode } from "./transient.mjs";
9
10
 
10
11
  const exec = promisify(execFile);
11
12
 
13
+ /** The run-record reason for a local job whose folder has no `.git` at its root (issue #524). */
14
+ export const LOCAL_FOLDER_NOT_A_REPO = "local-folder-not-a-repo";
15
+ /** The run-record reason for a local job whose repository has no commit at HEAD yet (issue #524). */
16
+ export const LOCAL_FOLDER_NO_COMMIT = "local-folder-no-commit";
17
+ /** The run-record reason for a local job whose repository git cannot resolve HEAD in, other than an unborn HEAD (issue #524). */
18
+ export const LOCAL_FOLDER_UNREADABLE_REPO = "local-folder-unreadable-repo";
19
+ /**
20
+ * The run-record reason for a local job whose folder is NAMED inside a job path (a `PI_DISPATCH_RUN_ROOTS` root or a cron
21
+ * trigger's `run.folder`) and RESOLVES, at prepare, to a directory outside it (issue #504 part B). A job container can
22
+ * write inside its own folder, so it can swap a link below a job path after the folder was checked; such a folder is
23
+ * refused rather than mounted wherever the link now points.
24
+ */
25
+ export const LOCAL_FOLDER_ESCAPED = "local-folder-escaped";
26
+ /** The run-record reason for a local job whose resolved folder is the envelope's folder or a directory above it (issue #504 part B). */
27
+ export const LOCAL_FOLDER_HOLDS_ENVELOPE = "local-folder-holds-envelope";
28
+
12
29
  /**
13
30
  * Prepare a LOCAL-FOLDER job. This is the zero-GitHub path: no token, no clone, no PR. The folder
14
31
  * on the operator's own machine becomes /workspace (bind-mounted read-write), edited in place.
@@ -27,11 +44,62 @@ const exec = promisify(execFile);
27
44
  * default keeps a directly-constructed call (tests, older wiring) honest: a job with no derived
28
45
  * context is a manual run.
29
46
  */
30
- export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaultGit, event = { source: "manual" }, fs = realFs }) {
31
- requirePath(fs, folder, `local folder does not exist: ${folder}`, "the local folder");
32
- requirePath(fs, join(folder, ".git"), `local folder is not a git repository (v1 requires one): ${folder}`, "the local folder's .git");
47
+ export async function prepareLocalWorkspace({ folder: named, task, jobDir, git = defaultGit, event = { source: "manual" }, fs = realFs, jobPaths = null, envelopeFile = null, portfolio = null }) {
48
+ requirePath(fs, named, `local folder does not exist: ${named}`, "the local folder");
49
+ // Issue #504 part B: the folder is RESOLVED here, once, and the resolved path is the one everything below reads and the
50
+ // one the container mounts (`workspace`). The raw string was mounted before, so a link below a job path swapped after
51
+ // the folder was checked (by the admin's run-root check at enqueue, or by nothing at all for a chained child) would
52
+ // have mounted whatever it pointed to, read-write. Resolving narrows the window to the moments between this line and
53
+ // the container start, and the two checks below judge the folder that will be mounted. Residual, named: a link above
54
+ // the resolved folder swapped inside that window is followed by the runtime when it mounts.
55
+ const folder = resolveFolder(fs, named);
56
+ const placement = judgePlacement(fs, named, folder, { jobPaths: typeof jobPaths === "function" ? jobPaths() : jobPaths, envelopeFile });
57
+ if (placement) return { outcome: "policy", reason: placement };
58
+ // Issue #524: a folder that is not a repository is its own determinate refusal, RETURNED with its own reason
59
+ // (CONST-RETRY-INFRA-ONLY), not a config throw. As a throw it reached the processor's config arm, which can only
60
+ // say "this deployment is misconfigured" (`config-refused`), about a folder the operator can fix with `git init`.
61
+ // Returned here it is a prepare-stage policy refusal like `sha-gone`: before the budget reserve, so it spends
62
+ // nothing, and `prepare.mjs` removes the job dir it made. The reason is a fixed token and carries no path, so the
63
+ // worker log and the run record stay as PII-free as they are. `pi-dispatch run` refuses the same folder before
64
+ // queueing; this is the check for a folder that stopped being a repository between queue and pickup, and for a
65
+ // cron trigger's folder, which nothing checks when it fires.
66
+ if (!presentAt(fs, join(folder, ".git"), "the local folder's .git")) return { outcome: "policy", reason: LOCAL_FOLDER_NOT_A_REPO };
33
67
 
34
- const sha = (await git(folder, ["rev-parse", "HEAD"])).trim();
68
+ // Issue #524: a HEAD git cannot resolve escaped as an untagged throw, a failed job with no reason. It is now
69
+ // refused by name, RETURNED before the reserve and before anything is written, like the not-a-repo one above,
70
+ // but only once the files git needs have been read back under `presentAt`'s rule (PR #528's review, round 2).
71
+ // git's exit code says "could not", never "why": `rev-parse --verify --quiet` exits 1 for an unborn HEAD AND for
72
+ // a missing commit object, a garbage branch ref, an unreadable `refs/heads`; it exits 128 for an empty `.git`
73
+ // AND for an EACCES on `.git/HEAD` or `.git/objects`, with the same "not a git repository" either way (all
74
+ // measured, git 2.x). Its text is no help either: a locale translates it, and it names host paths.
75
+ // - `local-folder-no-commit` only for a truly unborn HEAD: the follow-up `rev-parse --verify --quiet HEAD`
76
+ // also exits 1 AND `symbolic-ref -q HEAD` succeeds (HEAD names a branch that does not exist yet). A HEAD
77
+ // that resolves to a missing object or a non-commit, or a branch ref that cannot be read, is not "commit
78
+ // once" and must not be told so.
79
+ // - `local-folder-unreadable-repo` for every other 1 or 128.
80
+ // Before either refusal, `assertGitFilesReadable` reads what git needed. An error outside the determinate
81
+ // allow-list (`transient.mjs`: EACCES and EIO are deliberately transient) throws InfraRetry, so a filesystem
82
+ // that cannot answer right now is retried rather than refused for good. Any other git failure (a missing git
83
+ // binary, a signal) still throws as it did.
84
+ let sha;
85
+ try {
86
+ sha = (await git(folder, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"])).trim();
87
+ } catch (error) {
88
+ if (error?.code !== 1 && error?.code !== 128) throw error;
89
+ assertGitFilesReadable(fs, folder);
90
+ if (error.code === 1 && (await unbornHead(git, folder))) return { outcome: "policy", reason: LOCAL_FOLDER_NO_COMMIT };
91
+ return { outcome: "policy", reason: LOCAL_FOLDER_UNREADABLE_REPO };
92
+ }
93
+
94
+ // Issue #505: a confirmed portfolio job's snapshot, built BEFORE anything is written, so a refusal
95
+ // (`portfolio-snapshot-oversize`) or an InfraRetry (Valkey) leaves nothing behind but the directory prepare.mjs removes.
96
+ // Null from the builder means the live file no longer flags the job (or this host lost its envelope): no snapshot, and
97
+ // the job runs as an ordinary cron job.
98
+ let snapshot = null;
99
+ if (typeof portfolio === "function") {
100
+ snapshot = await portfolio();
101
+ if (snapshot?.outcome === "policy") return snapshot;
102
+ }
35
103
 
36
104
  fs.mkdirSync(jobDir, { recursive: true });
37
105
  // The outbox is the container's only signal channel back to the worker (INT-OUTBOX-CONTRACT). It is
@@ -57,14 +125,108 @@ export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaul
57
125
  const eventBody = {
58
126
  source: event.source,
59
127
  ...(event.trigger ? { trigger: event.trigger } : {}),
60
- folder: basename(folder),
128
+ folder: basename(named),
61
129
  sha,
62
130
  ...(event.source === "cron" ? { scheduledFor: event.scheduledFor ?? null, previousRunAt: event.previousRunAt ?? null } : {}),
63
131
  };
64
132
  fs.writeFileSync(join(jobDir, "event.json"), JSON.stringify(eventBody, null, 2), { mode: 0o444 });
133
+ // /job/portfolio.json (INT-CONTAINER-JOB-INPUTS), 0o444 beside event.json, so it reaches the container on the existing
134
+ // read-only /job mount with no new mount. Its presence is also what composes the portfolio persona in the runner.
135
+ if (typeof snapshot?.body === "string") fs.writeFileSync(join(jobDir, "portfolio.json"), snapshot.body, { mode: 0o444 });
65
136
 
66
137
  // The folder itself is /workspace (rw). No clone: local jobs edit in place.
67
- return { workspace: folder, jobDir, outboxDir, sha, materialised: written };
138
+ // `portfolio: true` only when the snapshot was written (issue #505): the plan collector requires prepare to have
139
+ // agreed, beside the pickup and the live file at collection, so a job that ran without the facts writes no plan.
140
+ return { workspace: folder, jobDir, outboxDir, sha, materialised: written, ...(typeof snapshot?.body === "string" ? { portfolio: true } : {}) };
141
+ }
142
+
143
+ /**
144
+ * The folder's real path (`realpathSync.native`). A determinate absence here (the folder vanished, or a link in it now
145
+ * dangles) is the "does not exist" config refusal `requirePath` gives; any other error is retried, `presentAt`'s rule.
146
+ */
147
+ function resolveFolder(fs, named) {
148
+ try {
149
+ return (fs.realpathSync?.native ?? fs.realpathSync)(named);
150
+ } catch (error) {
151
+ if (isDeterminateFsCode(error?.code)) {
152
+ const refusal = new Error(`local folder does not exist: ${named}`);
153
+ refusal.piDispatchConfig = true;
154
+ throw refusal;
155
+ }
156
+ throw new InfraRetry(`could not resolve the local folder (${error?.code ?? "unknown"}): ${basename(named)}`);
157
+ }
158
+ }
159
+
160
+ /** `p` is `root` or lies below it, as text. Both absolute and resolved. */
161
+ function insideOrEqual(p, root) {
162
+ return p === root || p.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
163
+ }
164
+
165
+ /** The identity of a path (`dev:ino`, links followed), or null when it cannot be read. */
166
+ function identityAt(fs, path) {
167
+ try {
168
+ return fileIdentity(fs.statSync(path, { bigint: true }));
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Where the RESOLVED folder may be mounted from (issue #504 part B), or the refusal reason:
176
+ * - `local-folder-escaped`: the folder is named inside a job path (its text, or its text against a job path's real
177
+ * path, lies inside a run root or a cron folder, or an ancestor of its text has a job path's identity) and its
178
+ * resolved path lies inside none of those job paths, judged
179
+ * by identity up the resolved path, so a firmlink or a case variant is the directory it names. A job path is a
180
+ * place a job can write; a link a job planted there must not carry another job's folder out of it. A folder named
181
+ * outside every job path (an operator's own `pi-dispatch run`) is not held to them, since nothing there is a job's;
182
+ * - `local-folder-holds-envelope`: the resolved folder is the envelope file's folder or a directory above it
183
+ * (`envelopeProtectedIdentities`), so the job could write its own bounds.
184
+ * `jobPaths` is `{ runRoots, cronFolders }` (schedules.mjs `envelopeJobPaths`), or null for none.
185
+ */
186
+ function judgePlacement(fs, named, folder, { jobPaths, envelopeFile }) {
187
+ const real = (p) => {
188
+ try {
189
+ return (fs.realpathSync?.native ?? fs.realpathSync)(p);
190
+ } catch {
191
+ return null;
192
+ }
193
+ };
194
+ const areas = [...(jobPaths?.runRoots ?? []), ...(jobPaths?.cronFolders ?? [])].filter((p) => typeof p === "string" && p.trim() !== "" && isAbsolute(p));
195
+ const text = resolve(named);
196
+ // The identities of the named path's own ancestors, walked up its TEXT (each stat follows links, as the kernel does):
197
+ // "named inside" is decided by identity too, so a case variant of a run root on a case-insensitive volume, or a
198
+ // firmlink spelling of it, is inside it, where a string comparison let it skip the check (PR #574's review).
199
+ const namedAncestors = new Set();
200
+ for (let p = text; areas.length > 0; p = dirname(p)) {
201
+ const id = identityAt(fs, p);
202
+ if (id !== null) namedAncestors.add(id);
203
+ if (dirname(p) === p) break;
204
+ }
205
+ const holding = areas.filter((area) => {
206
+ const r = real(area);
207
+ if (insideOrEqual(text, resolve(area)) || (r !== null && insideOrEqual(text, r))) return true;
208
+ const id = identityAt(fs, area);
209
+ return id !== null && namedAncestors.has(id);
210
+ });
211
+ if (holding.length > 0) {
212
+ const ids = new Set(holding.map((area) => identityAt(fs, area)).filter((id) => id !== null));
213
+ let inside = false;
214
+ for (let p = folder; ; p = dirname(p)) {
215
+ const id = identityAt(fs, p);
216
+ if (id !== null && ids.has(id)) {
217
+ inside = true;
218
+ break;
219
+ }
220
+ if (dirname(p) === p) break;
221
+ }
222
+ if (!inside) return LOCAL_FOLDER_ESCAPED;
223
+ }
224
+ if (envelopeFile !== null && envelopeFile !== undefined) {
225
+ const protectedIds = envelopeProtectedIdentities(envelopeFile, { realpathSync: fs.realpathSync?.native ?? fs.realpathSync, statSync: fs.statSync });
226
+ const id = identityAt(fs, folder);
227
+ if (id !== null && protectedIds.has(id)) return LOCAL_FOLDER_HOLDS_ENVELOPE;
228
+ }
229
+ return null;
68
230
  }
69
231
 
70
232
  /**
@@ -82,14 +244,23 @@ export async function prepareLocalWorkspace({ folder, task, jobDir, git = defaul
82
244
  * `.git` is a FILE in a worktree and in a submodule, and a symlinked project folder is ordinary.
83
245
  */
84
246
  function requirePath(fs, path, absentMessage, what) {
247
+ if (presentAt(fs, path, what)) return;
248
+ const refusal = new Error(absentMessage);
249
+ refusal.piDispatchConfig = true;
250
+ throw refusal;
251
+ }
252
+
253
+ /**
254
+ * True when something is at `path`, false when it is determinately absent, and a THROWN InfraRetry when the
255
+ * filesystem could not answer. The second half of `requirePath`, split out so the `.git` check can return its own
256
+ * refusal instead of throwing one, with the same rule about which errors mean absent (issue #316, below).
257
+ */
258
+ function presentAt(fs, path, what) {
85
259
  try {
86
260
  fs.statSync(path);
261
+ return true;
87
262
  } catch (error) {
88
- if (isDeterminateFsCode(error?.code)) {
89
- const refusal = new Error(absentMessage);
90
- refusal.piDispatchConfig = true;
91
- throw refusal;
92
- }
263
+ if (isDeterminateFsCode(error?.code)) return false;
93
264
  // Not absent, just unreachable right now. Throw the retryable class so the queue tries again
94
265
  // instead of spending the delivery on a verdict that is wrong by the time it is posted.
95
266
  // BASENAME, not the path (issue #289): an InfraRetry survives retries and its message becomes the
@@ -101,6 +272,70 @@ function requirePath(fs, path, absentMessage, what) {
101
272
  }
102
273
  }
103
274
 
275
+ /**
276
+ * True only for an unborn HEAD: one that names a branch with no commit yet (issue #524, PR #528's review). Both
277
+ * answers are git's exit codes. `rev-parse --verify --quiet HEAD` exits 0 when HEAD names SOME object, which after
278
+ * `HEAD^{commit}` failed means a missing or non-commit object; `symbolic-ref -q HEAD` fails for a detached HEAD and
279
+ * for a branch ref git cannot read (measured: a garbage ref and an EACCES `refs/heads` both exit 128 there).
280
+ */
281
+ async function unbornHead(git, folder) {
282
+ try {
283
+ await git(folder, ["rev-parse", "--verify", "--quiet", "HEAD"]);
284
+ return false;
285
+ } catch (error) {
286
+ if (error?.code !== 1) return false;
287
+ }
288
+ try {
289
+ await git(folder, ["symbolic-ref", "-q", "HEAD"]);
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Read back the files git needs to resolve HEAD, under `presentAt`'s rule: an absence the allow-list calls
298
+ * determinate is fine (the refusal stands), anything else throws InfraRetry so the job is retried (PR #528's review,
299
+ * round 2). `.git`; when it is a file (a worktree or submodule), the gitdir it names; `<gitdir>/HEAD`;
300
+ * `<gitdir>/config` when present; and the branch ref HEAD names, which is where an unreadable `refs/heads` shows.
301
+ * A ref that is absent is ordinary (an unborn branch, or one only in packed-refs). Messages carry basenames only.
302
+ */
303
+ function assertGitFilesReadable(fs, folder) {
304
+ const dotGit = join(folder, ".git");
305
+ const st = statOrRetry(fs, dotGit);
306
+ if (!st) return;
307
+ let gitDir = dotGit;
308
+ if (st.isFile?.()) {
309
+ const pointer = readOrRetry(fs, dotGit);
310
+ const named = pointer === null ? null : /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim();
311
+ if (!named) return;
312
+ gitDir = resolve(folder, named);
313
+ if (!statOrRetry(fs, gitDir)) return;
314
+ }
315
+ const head = readOrRetry(fs, join(gitDir, "HEAD"));
316
+ readOrRetry(fs, join(gitDir, "config"));
317
+ const ref = head === null ? null : /^ref:\s*(refs\/\S+)\s*$/m.exec(head)?.[1];
318
+ if (ref && !ref.split("/").includes("..")) readOrRetry(fs, join(gitDir, ref));
319
+ }
320
+
321
+ function statOrRetry(fs, path) {
322
+ try {
323
+ return fs.statSync(path);
324
+ } catch (error) {
325
+ if (isDeterminateFsCode(error?.code)) return null;
326
+ throw new InfraRetry(`could not read the local folder's git files (${error?.code ?? "unknown"}): ${basename(path)}`);
327
+ }
328
+ }
329
+
330
+ function readOrRetry(fs, path) {
331
+ try {
332
+ return fs.readFileSync(path, "utf8");
333
+ } catch (error) {
334
+ if (isDeterminateFsCode(error?.code)) return null;
335
+ throw new InfraRetry(`could not read the local folder's git files (${error?.code ?? "unknown"}): ${basename(path)}`);
336
+ }
337
+ }
338
+
104
339
  async function defaultGit(gitDir, args) {
105
340
  // This was the one copy of seven missing `core.fsmonitor=false`, which is why the flags are imported
106
341
  // now rather than restated (issue #286's sweep). Nothing was exploitable -- the only command below is