@edgehero/pi-dispatch 2.0.0 → 3.0.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 (80) hide show
  1. package/.env.example +44 -7
  2. package/README.md +14 -6
  3. package/deploy/com.pi-dispatch.worker.plist +1 -1
  4. package/deploy/docker-compose.yml +12 -0
  5. package/deploy/egress-proxy.conf +28 -3
  6. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  7. package/deploy/worker-env-wrapper.cmd +1 -1
  8. package/deploy/worker-env-wrapper.sh +3 -3
  9. package/package.json +9 -2
  10. package/src/allocation.mjs +731 -0
  11. package/src/backends.mjs +243 -0
  12. package/src/budget.mjs +40 -4
  13. package/src/cli.mjs +222 -11
  14. package/src/config.mjs +126 -5
  15. package/src/daemon-facts.mjs +3 -0
  16. package/src/deployment-venue.mjs +1 -0
  17. package/src/doctor.mjs +2316 -183
  18. package/src/dollar-budget.mjs +373 -0
  19. package/src/dollar-fingerprint.mjs +83 -0
  20. package/src/egress-cli.mjs +316 -0
  21. package/src/egress-proxy-state.mjs +35 -5
  22. package/src/egress.mjs +16 -3
  23. package/src/env-allowlist.mjs +142 -18
  24. package/src/env-file.mjs +194 -25
  25. package/src/envelope.mjs +413 -0
  26. package/src/exit-code.mjs +22 -0
  27. package/src/fleet-lease.mjs +85 -25
  28. package/src/get-token.mjs +16 -5
  29. package/src/git-dirty.mjs +67 -0
  30. package/src/github-app-setup.mjs +6 -3
  31. package/src/github-host.mjs +5 -3
  32. package/src/host-pi.mjs +19 -3
  33. package/src/identity.mjs +2 -1
  34. package/src/image-preflight.mjs +98 -24
  35. package/src/image-ref.mjs +37 -0
  36. package/src/import-pi.mjs +4 -2
  37. package/src/index.mjs +407 -62
  38. package/src/init.mjs +18 -0
  39. package/src/job-id.mjs +26 -3
  40. package/src/live-probes.mjs +24 -9
  41. package/src/model-catalog.mjs +297 -0
  42. package/src/model-endpoints.mjs +649 -0
  43. package/src/model-ref.mjs +151 -0
  44. package/src/models-json.mjs +262 -0
  45. package/src/money.mjs +144 -0
  46. package/src/octokit-log.mjs +65 -0
  47. package/src/outbox-plan.mjs +218 -0
  48. package/src/outbox.mjs +29 -9
  49. package/src/output-cap.mjs +157 -0
  50. package/src/packages.mjs +2 -2
  51. package/src/pause-windows.mjs +81 -2
  52. package/src/pi-model-loader.mjs +77 -0
  53. package/src/podman-stack.mjs +16 -3
  54. package/src/portfolio-snapshot.mjs +304 -0
  55. package/src/prepare-local.mjs +247 -12
  56. package/src/prepare.mjs +35 -3
  57. package/src/pricing.mjs +9 -5
  58. package/src/priorities.mjs +569 -0
  59. package/src/processor.mjs +603 -173
  60. package/src/project-id.mjs +17 -0
  61. package/src/projects.mjs +238 -0
  62. package/src/provider-key.mjs +32 -7
  63. package/src/provider-steering.mjs +214 -59
  64. package/src/queue.mjs +111 -6
  65. package/src/reserved-env.mjs +30 -0
  66. package/src/run-container.mjs +59 -5
  67. package/src/run-history.mjs +379 -24
  68. package/src/run-mirror.mjs +30 -0
  69. package/src/runtime-settings.mjs +104 -9
  70. package/src/schedules.mjs +33 -1
  71. package/src/scoped-limits.mjs +447 -27
  72. package/src/secrets.mjs +2 -1
  73. package/src/service.mjs +15 -4
  74. package/src/session-store.mjs +131 -6
  75. package/src/start.mjs +528 -40
  76. package/src/subscriptions.mjs +7 -3
  77. package/src/triggers-file.mjs +65 -4
  78. package/src/triggers.mjs +140 -9
  79. package/src/up.mjs +308 -34
  80. package/src/valkey-endpoint.mjs +3 -2
@@ -0,0 +1,77 @@
1
+ /**
2
+ * pi's own model loader, from the pi-coding-agent installed beside the worker, for `doctor` (issue #502's open
3
+ * question: can the worker's view of the overlay `models.json` disagree with pi's?).
4
+ *
5
+ * THE CHOICE, and why. The issue suggests running pi's loader in the job image. Doctor does run that image already,
6
+ * for the egress canary, but a probe that mounts the operator's overlay into it needs the canary's venue, job-user and
7
+ * SELinux handling, and every doctor run would pay a container start for it. The worker and the job image are built
8
+ * from one lockfile, so the pi-coding-agent that a checkout of this repository installs at the workspace root after
9
+ * `npm ci` is the pi the image carries, and doctor asks that one, in-process. Its version is named on doctor's line.
10
+ * Where it is not installed (a worker installed on its own: the worker package depends on pi-ai only), doctor says
11
+ * the comparison was not made rather than guessing. And only the PINNED pi counts (PR #551's review): an `npm i -g`
12
+ * layout resolves whatever pi-coding-agent sits beside the worker, a global pi CLI of any version, so the loader
13
+ * reports the worker's pinned pi-ai (its exact dependency; pi-ai and pi-coding-agent are released in lockstep, and a
14
+ * test holds this pin equal to the runner's pi-coding-agent pin, which is the image's) and doctor compares nothing
15
+ * when the pi-coding-agent version differs from it.
16
+ *
17
+ * Its own module because it is the one place doctor's code must touch `process.env`: pi reads `PI_OFFLINE` from the
18
+ * process environment, and the runtime is created with it set so asking about models can never reach the network.
19
+ * It is restored straight after.
20
+ *
21
+ * NOTHING ON DISK (PR #551's review, round 2). The runtime is handed an in-memory credential store and an in-memory
22
+ * models store. With pi's file-backed defaults it wrote an `auth.json` into a temporary directory, and pi's file
23
+ * backend creates the parent directory before it writes, so a write landing after the cleanup brought the directory
24
+ * back (17 were found in a TMPDIR); the default models store would also have written `models-store.json` beside the
25
+ * operator's overlay file. Neither store holds anything the question needs: composition reads the config only.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs";
29
+
30
+ /**
31
+ * `{ version, pinned, read(path) }`, or null when pi-coding-agent cannot be resolved from here. `version` is the
32
+ * resolved pi-coding-agent's, `pinned` the worker package's exact `@earendil-works/pi-ai` dependency. `read` answers
33
+ * `{ loads, has(provider, id) }`: `loads` is `ModelConfig.load(path).getError() === undefined`, and `has` asks a
34
+ * `ModelRuntime` created over the file (pi's provider composition included) for the model, with in-memory credential
35
+ * and models stores, so nothing is read from or written to disk beyond the file itself.
36
+ */
37
+ export async function loadPiModelLoader({ resolveEntry = (name) => import.meta.resolve(name), workerPackage = new URL("../package.json", import.meta.url) } = {}) {
38
+ let ModelConfig;
39
+ let ModelRuntime;
40
+ let AuthStorage;
41
+ let InMemoryCodingAgentModelsStore;
42
+ let version;
43
+ let pinned = null;
44
+ try {
45
+ pinned = JSON.parse(readFileSync(workerPackage, "utf8"))?.dependencies?.["@earendil-works/pi-ai"] ?? null;
46
+ } catch {
47
+ // No pin to compare against: doctor then says it cannot tell, rather than comparing with any pi.
48
+ }
49
+ try {
50
+ const entry = resolveEntry("@earendil-works/pi-coding-agent");
51
+ ({ ModelConfig } = await import(new URL("core/model-config.js", entry).href));
52
+ ({ ModelRuntime } = await import(new URL("core/model-runtime.js", entry).href));
53
+ ({ AuthStorage } = await import(new URL("core/auth-storage.js", entry).href));
54
+ ({ InMemoryCodingAgentModelsStore } = await import(new URL("core/models-store.js", entry).href));
55
+ version = JSON.parse(readFileSync(new URL("../package.json", entry), "utf8")).version;
56
+ } catch {
57
+ return null;
58
+ }
59
+ return {
60
+ version,
61
+ pinned,
62
+ async read(path) {
63
+ const config = await ModelConfig.load(path);
64
+ const loads = config.getError() === undefined;
65
+ // env-internal PI_OFFLINE: pi's own switch, set here so asking pi about models never reaches the network.
66
+ const offline = process.env.PI_OFFLINE;
67
+ process.env.PI_OFFLINE = "1";
68
+ try {
69
+ const runtime = await ModelRuntime.create({ modelsPath: path, credentials: AuthStorage.inMemory(), modelsStore: new InMemoryCodingAgentModelsStore(), refreshOnCreate: false });
70
+ return { loads, has: (provider, id) => runtime.getModel(provider, id) !== undefined };
71
+ } finally {
72
+ if (offline === undefined) delete process.env.PI_OFFLINE;
73
+ else process.env.PI_OFFLINE = offline;
74
+ }
75
+ },
76
+ };
77
+ }
@@ -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
+ }