@edgehero/pi-dispatch 4.3.1 → 5.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 (256) hide show
  1. package/.env.example +84 -3
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +190 -29
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Display history, never a notification queue (INT-HOOK-RUNS-KEY, #775). Deleting hook:* changes no hook or job.
3
+ * Each completion appends one bounded, versioned record; the key expires seven days after the last append.
4
+ * The small memory drain absorbs simultaneous completions without making a hook wait for Valkey. It never retries:
5
+ * a failed append is lost and named. An operation that timed out still owns the drain until it ACTUALLY settles,
6
+ * because ioredis queues commands while offline and a timeout alone cannot bound that queue.
7
+ */
8
+ import { publishedJobId, LIVE_JOB_ID_RE } from "./live-jobs.mjs";
9
+ import { WORKER_NAME_RE } from "./worker-name.mjs";
10
+ import { RECORD_REASON_RE } from "./record-reason.mjs";
11
+
12
+ export const HOOK_HOSTS = "hook:hosts";
13
+ export const hookRunsKey = (name) => `hook:h:${name}`;
14
+ export const HOOK_RUN_VERSION = 1;
15
+ export const HOOK_RUNS_MAX = 20;
16
+ export const HOOK_RUN_BYTES = 1024;
17
+ export const HOOK_HOSTS_MAX = 64;
18
+ export const HOOK_PENDING_MAX = 64;
19
+ export const HOOK_RUNS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
+ /** The portable union, not this reader's platform: a Linux worker may be read from macOS or Windows. */
21
+ export const HOOK_SIGNALS = Object.freeze(["SIGABRT", "SIGALRM", "SIGBUS", "SIGCHLD", "SIGCLD", "SIGCONT", "SIGEMT", "SIGFPE", "SIGHUP", "SIGILL", "SIGINFO", "SIGINT", "SIGIO", "SIGIOT", "SIGKILL", "SIGLOST", "SIGPIPE", "SIGPOLL", "SIGPROF", "SIGPWR", "SIGQUIT", "SIGSEGV", "SIGSTKFLT", "SIGSTOP", "SIGSYS", "SIGTERM", "SIGTRAP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGUNUSED", "SIGURG", "SIGUSR1", "SIGUSR2", "SIGVTALRM", "SIGWINCH", "SIGXCPU", "SIGXFSZ", "SIGBREAK"]);
22
+ export const HOOK_DETAILS = Object.freeze(["exit", "timeout", "spawn", "unresolvable", "argv-unusable", "signal-unknown", ...HOOK_SIGNALS.map((signal) => `signal-${signal}`)]);
23
+ const instant = (value) => Number.isSafeInteger(value) && value >= 0;
24
+ // Windows reports the full unsigned process status (including NTSTATUS), not POSIX's low eight bits.
25
+ const codeOf = (value) => Number.isInteger(value) && value >= 0 && value <= 0xffffffff ? value : null;
26
+ const outcomeOf = (value) => value === "failed" || value === "policy" ? value : null;
27
+ const reasonOf = (value) => typeof value === "string" && RECORD_REASON_RE.test(value) ? value : "infra";
28
+
29
+ export function buildHookRun(fields, at) {
30
+ return { v: HOOK_RUN_VERSION, at, jobId: publishedJobId(fields?.jobId), outcome: outcomeOf(fields?.outcome), reason: reasonOf(fields?.reason), detail: HOOK_DETAILS.includes(fields?.detail) ? fields.detail : "signal-unknown", code: codeOf(fields?.code) };
31
+ }
32
+
33
+ /** Another host's text is projected again, including records passed directly to the model boundary. */
34
+ export function parseHookRun(raw) {
35
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > HOOK_RUN_BYTES) return null;
36
+ let value;
37
+ try { value = JSON.parse(raw); } catch { return null; }
38
+ if (!value || value.v !== HOOK_RUN_VERSION || !instant(value.at) || !(value.jobId === null || typeof value.jobId === "string" && LIVE_JOB_ID_RE.test(value.jobId)) || !(value.outcome === null || outcomeOf(value.outcome)) || typeof value.reason !== "string" || !RECORD_REASON_RE.test(value.reason) || !HOOK_DETAILS.includes(value.detail) || !(value.code === null || codeOf(value.code) === value.code)) return null;
39
+ return { v: HOOK_RUN_VERSION, at: value.at, jobId: value.jobId, outcome: value.outcome, reason: value.reason, detail: value.detail, code: value.code };
40
+ }
41
+
42
+ function bounded(promise, timeoutMs = 2000) {
43
+ return new Promise((resolve, reject) => {
44
+ const timer = setTimeout(() => reject(Error("timeout")), timeoutMs);
45
+ Promise.resolve(promise).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
46
+ });
47
+ }
48
+ const validName = (name) => typeof name === "string" && WORKER_NAME_RE.test(name);
49
+
50
+ /** No message from a client, record, executable or environment reaches this diagnostic. */
51
+ export function makeHookRunsPublisher({ redis, name, now = Date.now, log = () => {}, timeoutMs = 2000 }) {
52
+ const pending = [];
53
+ let draining = false;
54
+ let closed = false;
55
+ let pruneAt = null;
56
+ let pruneCursor = "0";
57
+ const note = (reason) => { try { log("hook_runs_publish_failed", { reason }); } catch {} };
58
+ const prune = async (at) => {
59
+ if (closed || pruneAt !== null && at - pruneAt < 15_000) return;
60
+ pruneAt = at;
61
+ const found = await redis.sscan(HOOK_HOSTS, pruneCursor, "COUNT", HOOK_HOSTS_MAX);
62
+ if (!Array.isArray(found) || typeof found[0] !== "string" || !Array.isArray(found[1])) return;
63
+ pruneCursor = found[0];
64
+ const names = found[1].slice(0, HOOK_HOSTS_MAX).filter((member) => validName(member) && member !== name);
65
+ if (closed || !names.length) return;
66
+ const pipe = redis.pipeline();
67
+ // Checking and removing together cannot erase an index entry another host has just republished.
68
+ for (const member of names) pipe.eval("if redis.call('EXISTS', KEYS[1]) == 0 then return redis.call('SREM', KEYS[2], ARGV[1]) end return 0", 2, hookRunsKey(member), HOOK_HOSTS, member);
69
+ const result = await pipe.exec();
70
+ if (!Array.isArray(result) || result.length !== names.length || result.some((row) => !Array.isArray(row) || row[0])) throw Error("unavailable");
71
+ };
72
+ const drain = async () => {
73
+ draining = true;
74
+ while (!closed && pending.length) {
75
+ const record = pending.shift();
76
+ let said = false;
77
+ const fail = () => { if (!said && !closed) { said = true; note("unavailable"); } };
78
+ const timer = setTimeout(fail, timeoutMs);
79
+ timer.unref?.();
80
+ try {
81
+ const key = hookRunsKey(name);
82
+ const tx = redis.multi().lpush(key, JSON.stringify(record)).ltrim(key, 0, HOOK_RUNS_MAX - 1).pexpire(key, HOOK_RUNS_TTL_MS).sadd(HOOK_HOSTS, name);
83
+ const answer = await tx.exec();
84
+ if (!Array.isArray(answer) || answer.length !== 4 || answer.some((row) => !Array.isArray(row) || row[0])) fail();
85
+ else await prune(record.at);
86
+ } catch { fail(); }
87
+ finally { clearTimeout(timer); }
88
+ }
89
+ draining = false;
90
+ };
91
+ return {
92
+ publish(fields) {
93
+ if (closed) return;
94
+ if (!validName(name)) { note("invalid-host"); return; }
95
+ if (pending.length + (draining ? 1 : 0) >= HOOK_PENDING_MAX) { note("full"); return; }
96
+ let at;
97
+ try { at = now(); } catch { note("invalid-time"); return; }
98
+ if (!instant(at)) { note("invalid-time"); return; }
99
+ pending.push(buildHookRun(fields, at));
100
+ if (!draining) void drain();
101
+ },
102
+ close() { closed = true; pending.length = 0; },
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Bounded discovery: live names first, then at most four SSCAN pages, no SMEMBERS. COUNT is a hint, so even a large
108
+ * response is cut before issuing row commands. SCARD reports an incomplete index walk without calling it empty.
109
+ * A lost index still reads known live hosts; an unreadable index makes that answer partial, never complete.
110
+ */
111
+ async function hostNames(redis, names, timeoutMs, stopped) {
112
+ const live = [...new Set(names.filter(validName))].sort();
113
+ const wanted = new Set(live.slice(0, HOOK_HOSTS_MAX));
114
+ const indexed = new Set();
115
+ const members = new Set();
116
+ let total = 0;
117
+ let partial = live.length > HOOK_HOSTS_MAX;
118
+ let indexFailed = false;
119
+ let cursor = "0";
120
+ try {
121
+ total = await bounded(redis.scard(HOOK_HOSTS), timeoutMs);
122
+ if (!Number.isSafeInteger(total) || total < 0) throw Error("invalid");
123
+ if (total > 0) for (let page = 0; page < 4 && !stopped(); page++) {
124
+ const result = await bounded(redis.sscan(HOOK_HOSTS, cursor, "COUNT", HOOK_HOSTS_MAX), timeoutMs);
125
+ if (!Array.isArray(result) || typeof result[0] !== "string" || !Array.isArray(result[1])) throw Error("invalid");
126
+ cursor = result[0];
127
+ for (const name of result[1].slice(0, HOOK_HOSTS_MAX * 4)) {
128
+ members.add(name);
129
+ if (validName(name)) { indexed.add(name); if (wanted.size < HOOK_HOSTS_MAX) wanted.add(name); }
130
+ }
131
+ if (cursor === "0" || wanted.size === HOOK_HOSTS_MAX) break;
132
+ }
133
+ partial ||= cursor !== "0" || total !== members.size || indexed.size !== members.size;
134
+ } catch { partial = true; indexFailed = true; }
135
+ // With an incomplete walk the unseen members may overlap the omitted live names. Count only the union we SAW,
136
+ // explicitly a floor; adding the two remainders would count a large fleet's same hosts twice.
137
+ const more = Math.max(0, new Set([...live, ...indexed]).size - wanted.size);
138
+ const moreUnknown = indexFailed || cursor !== "0" || total !== members.size;
139
+ return { names: [...wanted], more, moreUnknown, partial: partial || more > 0 };
140
+ }
141
+
142
+ /** Readers write nothing. Expired index members are pruned by the next publisher, not by a console request. */
143
+ export async function readHookRuns(redis, { names = [], timeoutMs = 2000 } = {}) {
144
+ let stopped = false;
145
+ // Register the WHOLE-read timer before issuing any command. Per-command bounds alone can turn four slow
146
+ // discovery pages and one row read into ten seconds; late settlement must never start follow-up commands.
147
+ const work = Promise.resolve().then(() => readHookRunsInner(redis, names, timeoutMs, () => stopped));
148
+ try { return await bounded(work, timeoutMs); }
149
+ catch { return { hosts: [], more: 0, moreUnknown: true, partial: true }; }
150
+ finally { stopped = true; }
151
+ }
152
+
153
+ async function readHookRunsInner(redis, names, timeoutMs, stopped) {
154
+ const selected = await hostNames(redis, names, timeoutMs, stopped);
155
+ if (stopped()) return { hosts: [], more: selected.more, moreUnknown: true, partial: true };
156
+ const hosts = await Promise.all(selected.names.map(async (name) => {
157
+ try {
158
+ const key = hookRunsKey(name);
159
+ const [raw, ttl] = await Promise.all([bounded(redis.lrange(key, 0, HOOK_RUNS_MAX), timeoutMs), bounded(redis.pttl(key), timeoutMs)]);
160
+ if (!Array.isArray(raw) || raw.length > HOOK_RUNS_MAX || !Number.isSafeInteger(ttl)) throw Error("invalid");
161
+ if (raw.length === 0 && ttl === -2) return { name, runs: [], absent: true };
162
+ if (ttl <= 0 || ttl > HOOK_RUNS_TTL_MS || raw.length === 0) throw Error("invalid");
163
+ const runs = raw.map(parseHookRun);
164
+ if (runs.some((record) => record === null)) throw Error("invalid");
165
+ return { name, runs };
166
+ } catch { return { name, runs: [], unreadable: true }; }
167
+ }));
168
+ return { hosts: hosts.sort((a, b) => a.name.localeCompare(b.name)), more: selected.more, moreUnknown: selected.moreUnknown, partial: selected.partial || hosts.some((host) => host.unreadable) };
169
+ }
170
+
171
+ /** Public display/model DTO. Recomputing counts prevents a polluted DTO inventing a successful hook history. */
172
+ export function hookRunsView(value) {
173
+ const hosts = [];
174
+ const input = Array.isArray(value?.hosts) ? value.hosts : [];
175
+ const omitted = Math.max(0, input.length - HOOK_HOSTS_MAX);
176
+ let partial = value?.partial !== false || omitted > 0;
177
+ const seen = new Set();
178
+ for (const host of input.slice(0, HOOK_HOSTS_MAX)) {
179
+ if (!validName(host?.name) || seen.has(host.name)) { partial = true; continue; }
180
+ seen.add(host.name);
181
+ const raw = Array.isArray(host.runs) ? host.runs : [];
182
+ const runs = raw.slice(0, HOOK_RUNS_MAX).map((run) => { try { return parseHookRun(JSON.stringify(run)); } catch { return null; } });
183
+ const unreadable = host.unreadable === true || !Array.isArray(host.runs) || raw.length > HOOK_RUNS_MAX || runs.some((run) => !run);
184
+ const counts = unreadable ? null : Object.fromEntries([...new Set(runs.map((run) => run.detail))].map((detail) => [detail, runs.filter((run) => run.detail === detail).length]));
185
+ hosts.push({ name: host.name, unreadable, absent: !unreadable && (host.absent === true || runs.length === 0), runs: unreadable ? [] : runs, counts });
186
+ }
187
+ return { hosts, more: (Number.isSafeInteger(value?.more) && value.more >= 0 ? value.more : 0) + omitted, moreUnknown: value?.moreUnknown === true, partial: partial || hosts.some((host) => host.unreadable) };
188
+ }
@@ -45,8 +45,8 @@ export const HOST_BUDGET_KEYS = Object.freeze({
45
45
  });
46
46
 
47
47
  /**
48
- * How often a job the budget deferred asks again. Its own value, like every re-check cadence, because nothing records WHY
49
- * a job sits in the delayed set and the wake instant is the only evidence there is: distinct from the scope re-check
48
+ * How often a job the budget deferred asks again. Its own value, like every re-check cadence, so the wake instants of the
49
+ * different deferrals stay apart (the reason itself is recorded, `defer-reasons.mjs`): distinct from the scope re-check
50
50
  * (5 s), the endpoint re-check (7 s), the wait throttle floor (11 s) and the supersede re-ask (15 s). A test keeps them
51
51
  * apart. Short, because a hold keeps the room meanwhile: the wait is the latency of a freed slot, not a queue position.
52
52
  */
@@ -306,7 +306,7 @@ export function rankHolds(waiters, ledger, minJobsOf = () => 0) {
306
306
 
307
307
  /**
308
308
  * THE ADMISSION RULE (PURE): may `ask` (`{ id, project, memMiB, cpuCenti }`) start now? `{ ok: true }`, or `{ ok: false,
309
- * why }` with `why` `share` (its project would hold more than its `hostShare` of the budget) or `budget` (what runs, plus
309
+ * why }` with `why` `share` (its project would hold more than its `hostShare` of the budget) or `memory`, `cpu`, `count` (what runs, plus
310
310
  * `ask`, plus every hold ranked above it, does not fit in memory, in CPU or in the job COUNT). `holds` is `rankHolds`'
311
311
  * order; a job that holds no hold has every hold above it. The share is judged first, so a job its own project's share
312
312
  * stops is not reported as waiting for the budget (and so does not hold room it could not use).
@@ -328,7 +328,10 @@ export function admit({ budget, ledger, holds, ask, shareOf = () => null }) {
328
328
  const counted = [...ledger, ask, ...(rank === -1 ? holds : holds.slice(0, rank))];
329
329
  const need = sumOf(counted);
330
330
  const count = Number.isSafeInteger(budget.count) ? budget.count : null;
331
- if (!fits(need.memMiB, budget.memMiB) || !fits(need.cpuCenti, budget.cpuCenti) || !fits(counted.length, count)) return { ok: false, why: "budget" };
331
+ // A stable order explains the first blocked dimension without changing admission or ranked holds.
332
+ if (!fits(need.memMiB, budget.memMiB)) return { ok: false, why: "memory" };
333
+ if (!fits(need.cpuCenti, budget.cpuCenti)) return { ok: false, why: "cpu" };
334
+ if (!fits(counted.length, count)) return { ok: false, why: "count" };
332
335
  return { ok: true };
333
336
  }
334
337
 
@@ -643,7 +646,7 @@ export function makeHostBudget({ settings, jobDefault = { memMiB: 4096, cpuCenti
643
646
  /**
644
647
  * THE GATE, synchronous: `{ admitted: true }` with the hold taken under `ticket`, or `{ admitted: false, why }`:
645
648
  * `unseeded` (the boot listing of the job's `venue` is not read yet) and `running-here` (the ledger already holds
646
- * this id, or an orphan carries the container `name` this pickup will use) make no waiter; `budget` and `share`
649
+ * this id, or an orphan carries the container `name` this pickup will use) make no waiter; dimension refusals and `share`
647
650
  * keep (or make) the job a waiter. `getState` is the job's own, kept for `verify`.
648
651
  */
649
652
  gate({ id, ticket = null, project = null, size, venue = null, name = null, getState = null, limits = scopedLimits() }) {
@@ -0,0 +1,72 @@
1
+ /** Shared host-clone primitives. The credential helper lives outside every agent-reachable tree (#771). */
2
+ import { execFile } from "node:child_process";
3
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, win32 } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { GIT_SAFE_CONFIG, storeGitEnv } from "./git-hardening.mjs";
8
+
9
+ const exec = promisify(execFile);
10
+ export const HOST_CLONE_FLAGS = Object.freeze([...GIT_SAFE_CONFIG, "-c", "protocol.ext.allow=never", "-c", "credential.helper=", "--no-pager"]);
11
+ export const hardenedCloneArgs = (args) => [...HOST_CLONE_FLAGS, ...args];
12
+
13
+ export function createAskpassHelper() {
14
+ const dir = mkdtempSync(join(tmpdir(), "pi-askpass-"));
15
+ const path = join(dir, "askpass.sh");
16
+ try {
17
+ writeFileSync(path, "#!/bin/sh\nprintf '%s' \"$GIT_ASKPASS_TOKEN\"\n", { mode: 0o700 });
18
+ chmodSync(path, 0o700);
19
+ return { dir, path };
20
+ } catch (error) {
21
+ rmSync(dir, { recursive: true, force: true });
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ export async function defaultHostGit(cwd, args, { env, signal, timeout } = {}) {
27
+ const { stdout } = await exec("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, env, ...(timeout ? { timeout } : {}), ...(signal ? { signal } : {}) });
28
+ return stdout;
29
+ }
30
+
31
+ /** The determinate missing-ref class, shared with the job preparer's existing refusal. */
32
+ export function isCloneRefGone(error) {
33
+ const text = `${error?.stderr ?? ""}\n${error?.message ?? ""}`.toLowerCase();
34
+ return ["couldn't find remote ref", "not our ref", "unadvertised object", "did not send all necessary objects", "reference is not a tree"].some((part) => text.includes(part));
35
+ }
36
+
37
+ /** storeGitEnv's trust switches are shared; Windows keeps absolute drive/UNC tool paths with its own separator. */
38
+ export function hostCloneEnvironment({ home, path, platform = process.platform }) {
39
+ const clean = storeGitEnv({ home, path });
40
+ delete clean.GIT_DIR;
41
+ if (platform === "win32") clean.PATH = String(path ?? "").split(";").filter((entry) => win32.isAbsolute(entry)).join(";");
42
+ return clean;
43
+ }
44
+
45
+ /**
46
+ * A credential-free checkout after the one authenticated fetch. No materialisation, prompt, session or agent inputs.
47
+ * The empty helper home and allowlisted environment also stop a repository's attributes selecting a host filter.
48
+ */
49
+ export async function cloneHostWorkspace({ workspace, remote, ref, token, env = process.env, git = defaultHostGit, askpass = createAskpassHelper, platform = process.platform, signal } = {}) {
50
+ const url = new URL(remote);
51
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error("sandbox clone requires a credential-free configured http(s) remote");
52
+ const helper = askpass();
53
+ try {
54
+ const home = join(helper.dir, "home");
55
+ mkdirSync(home, { mode: 0o700 });
56
+ mkdirSync(workspace, { recursive: true, mode: 0o700 });
57
+ // env-internal PATH: use only absolute host tool directories, as the checkpoint-store Git does.
58
+ const clean = hostCloneEnvironment({ home, path: env.PATH, platform });
59
+ // env-internal GIT_SSL_CAINFO: an operator's private CA trust input, never repository content or a container env.
60
+ if (typeof env.GIT_SSL_CAINFO === "string") clean.GIT_SSL_CAINFO = env.GIT_SSL_CAINFO;
61
+ const run = (args, net = false) => git(workspace, hardenedCloneArgs(args), { timeout: 120_000, env: net ? { ...clean, GIT_ASKPASS: helper.path, GIT_ASKPASS_TOKEN: token } : clean, ...(signal ? { signal } : {}) });
62
+ await run(["init"]);
63
+ await run(["remote", "add", "origin", remote]);
64
+ await run(["fetch", "--depth=1", "--no-tags", "--no-recurse-submodules", "origin", ref], true);
65
+ await run(["checkout", "--detach", "FETCH_HEAD"]);
66
+ const sha = String(await run(["rev-parse", "HEAD"])).trim();
67
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sha)) throw new Error("sandbox clone returned no commit id");
68
+ return { workspace, sha };
69
+ } finally {
70
+ rmSync(helper.dir, { recursive: true, force: true });
71
+ }
72
+ }