@edgehero/pi-dispatch 4.3.1 → 5.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 (260) hide show
  1. package/.env.example +84 -3
  2. package/README.md +21 -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 +118 -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 +136 -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 +325 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-devices.mjs +372 -0
  60. package/src/console-install.mjs +239 -0
  61. package/src/console-open.mjs +99 -0
  62. package/src/console-probe.mjs +106 -0
  63. package/src/console-tokens.mjs +459 -0
  64. package/src/container-names.mjs +51 -0
  65. package/src/container-spec.mjs +20 -1
  66. package/src/cpu-reserve.mjs +4 -1
  67. package/src/defer-reasons.mjs +200 -0
  68. package/src/defer-write.mjs +208 -0
  69. package/src/deployment-venue.mjs +23 -6
  70. package/src/docker-run.mjs +11 -0
  71. package/src/doctor-bundles.mjs +130 -0
  72. package/src/doctor-model-proposals.mjs +109 -0
  73. package/src/doctor-openshell.mjs +238 -0
  74. package/src/doctor-results.mjs +602 -0
  75. package/src/doctor-spawner.mjs +215 -0
  76. package/src/doctor.mjs +1657 -196
  77. package/src/dollar-budget.mjs +212 -3
  78. package/src/endpoint-status.mjs +100 -0
  79. package/src/env-allowlist.mjs +96 -6
  80. package/src/env-file.mjs +6 -4
  81. package/src/envelope.mjs +5 -3
  82. package/src/file-expect.mjs +47 -0
  83. package/src/forgejo-host.mjs +42 -5
  84. package/src/forgejo-prompt.mjs +5 -4
  85. package/src/forges.mjs +33 -3
  86. package/src/fork-job.mjs +58 -0
  87. package/src/fork-policy.mjs +23 -0
  88. package/src/fork-store.mjs +338 -0
  89. package/src/get-token.mjs +1 -1
  90. package/src/git-dirty.mjs +119 -14
  91. package/src/git-hardening.mjs +85 -0
  92. package/src/github-host.mjs +31 -6
  93. package/src/github-prompt.mjs +7 -16
  94. package/src/gitlab-host.mjs +42 -3
  95. package/src/gitlab-prompt.mjs +5 -4
  96. package/src/held-document.mjs +23 -0
  97. package/src/held-replies.mjs +325 -0
  98. package/src/held-settle.mjs +266 -0
  99. package/src/hook-runs.mjs +188 -0
  100. package/src/host-budget.mjs +8 -5
  101. package/src/host-clone.mjs +72 -0
  102. package/src/host-loads.mjs +557 -0
  103. package/src/host-registry.mjs +66 -6
  104. package/src/host-telemetry.mjs +92 -0
  105. package/src/http-body.mjs +78 -0
  106. package/src/image-preflight.mjs +156 -13
  107. package/src/import-pi.mjs +60 -172
  108. package/src/inbox-relay.mjs +482 -0
  109. package/src/inbox-settle.mjs +196 -0
  110. package/src/index.mjs +734 -129
  111. package/src/init.mjs +35 -5
  112. package/src/job-data-version.mjs +75 -0
  113. package/src/job-id.mjs +30 -15
  114. package/src/job-layout.mjs +22 -0
  115. package/src/job-size.mjs +16 -0
  116. package/src/live-jobs.mjs +14 -6
  117. package/src/live-probes.mjs +6 -2
  118. package/src/live-transcript.mjs +872 -0
  119. package/src/loaded-record.mjs +609 -0
  120. package/src/mcp-config.mjs +4 -2
  121. package/src/mcp-preflight.mjs +36 -5
  122. package/src/model-attempts.mjs +84 -0
  123. package/src/model-catalog.mjs +83 -0
  124. package/src/model-cooldowns.mjs +22 -0
  125. package/src/model-failover-protocol.mjs +11 -0
  126. package/src/model-failover.mjs +134 -0
  127. package/src/model-observation.mjs +50 -0
  128. package/src/model-proposal-protocol.mjs +21 -0
  129. package/src/model-proposals.mjs +75 -0
  130. package/src/model-ref.mjs +67 -10
  131. package/src/model-roles-file.mjs +24 -0
  132. package/src/model-roles.mjs +73 -0
  133. package/src/model-status.mjs +34 -0
  134. package/src/models-json.mjs +12 -2
  135. package/src/money.mjs +1 -1
  136. package/src/oauth-openai.mjs +121 -0
  137. package/src/older-state.mjs +249 -0
  138. package/src/on-failure.mjs +17 -13
  139. package/src/open-browser.mjs +31 -2
  140. package/src/opener-cli.mjs +135 -0
  141. package/src/opener-protocol.mjs +27 -0
  142. package/src/opener-terminal.mjs +128 -0
  143. package/src/openshell-cli.mjs +155 -0
  144. package/src/openshell-policy.mjs +163 -0
  145. package/src/outbox.mjs +14 -0
  146. package/src/output-cap.mjs +1 -0
  147. package/src/package-canary-record.mjs +26 -0
  148. package/src/package-canary.mjs +135 -0
  149. package/src/package-names.mjs +9 -0
  150. package/src/package-stage.mjs +418 -0
  151. package/src/packages.mjs +24 -16
  152. package/src/parked.mjs +2 -1
  153. package/src/pause-windows.mjs +136 -17
  154. package/src/podman-stack.mjs +40 -14
  155. package/src/portfolio-snapshot.mjs +26 -7
  156. package/src/prepare-github.mjs +41 -77
  157. package/src/prepare-local.mjs +32 -2
  158. package/src/prepare.mjs +45 -11
  159. package/src/private-store-boundary.mjs +70 -0
  160. package/src/processor.mjs +1550 -173
  161. package/src/projects.mjs +23 -0
  162. package/src/prompt-fence.mjs +18 -0
  163. package/src/provider-key.mjs +7 -0
  164. package/src/provider-steering.mjs +1 -0
  165. package/src/question-context.mjs +35 -0
  166. package/src/question-group-runtime.mjs +91 -0
  167. package/src/question-groups.mjs +171 -0
  168. package/src/queue.mjs +264 -55
  169. package/src/reason-catalog.mjs +448 -0
  170. package/src/receiver-status.mjs +230 -0
  171. package/src/record-reason.mjs +37 -0
  172. package/src/recovery-queue.mjs +566 -0
  173. package/src/redact-secrets.mjs +219 -0
  174. package/src/reserve-estimate.mjs +101 -0
  175. package/src/reserved-env.mjs +36 -1
  176. package/src/retention.mjs +67 -0
  177. package/src/run-budget-ledger.mjs +283 -0
  178. package/src/run-checkpoint.mjs +627 -0
  179. package/src/run-container.mjs +749 -33
  180. package/src/run-history.mjs +791 -45
  181. package/src/run-mirror.mjs +75 -7
  182. package/src/run-progress.mjs +751 -0
  183. package/src/runtime-observations.mjs +3 -1
  184. package/src/runtime-settings.mjs +272 -37
  185. package/src/sandbox-cli.mjs +78 -2
  186. package/src/sandbox-deployment.mjs +51 -0
  187. package/src/sandbox-gateway-protocol.mjs +378 -0
  188. package/src/sandbox-hosts.mjs +100 -0
  189. package/src/sandbox-inventory.mjs +83 -0
  190. package/src/sandbox-policy.mjs +225 -0
  191. package/src/sandbox-prepare-requests.mjs +145 -0
  192. package/src/sandbox-prepare.mjs +285 -0
  193. package/src/sandbox-refs.mjs +70 -0
  194. package/src/sandbox-session-monitor.mjs +4 -0
  195. package/src/sandbox-session-watch.mjs +124 -0
  196. package/src/sandbox-sessions.mjs +161 -0
  197. package/src/sandbox-store.mjs +63 -6
  198. package/src/sandbox-workspace-guard.mjs +39 -0
  199. package/src/sandbox.mjs +27 -200
  200. package/src/scheduler-stall-guard.mjs +14 -8
  201. package/src/schedules.mjs +19 -6
  202. package/src/secret-bundles.mjs +245 -0
  203. package/src/secret-profiles.mjs +103 -3
  204. package/src/secrets.mjs +19 -9
  205. package/src/service-env.mjs +38 -5
  206. package/src/service.mjs +638 -106
  207. package/src/session-key.mjs +60 -0
  208. package/src/session-store.mjs +168 -10
  209. package/src/setup-contract.mjs +40 -0
  210. package/src/setup-egress.mjs +113 -0
  211. package/src/setup-host-entry.mjs +36 -0
  212. package/src/setup-host.mjs +242 -0
  213. package/src/setup-lock.mjs +41 -0
  214. package/src/size-records.mjs +15 -3
  215. package/src/start.mjs +1353 -135
  216. package/src/status-comment.mjs +415 -0
  217. package/src/stderr-transcript.mjs +318 -0
  218. package/src/steer-ack.mjs +122 -0
  219. package/src/steer-cli.mjs +213 -0
  220. package/src/steer-framing.mjs +185 -0
  221. package/src/steer-inbox.mjs +693 -0
  222. package/src/store-version.mjs +139 -0
  223. package/src/subscription-config.mjs +56 -0
  224. package/src/subscription-limits.mjs +3 -0
  225. package/src/subscription-quota.mjs +259 -0
  226. package/src/subscription-runtime.mjs +123 -0
  227. package/src/subscription-status.mjs +42 -0
  228. package/src/subscription-wait.mjs +11 -0
  229. package/src/subscriptions.mjs +7 -1
  230. package/src/thinking-level.mjs +19 -0
  231. package/src/touch-outbox.mjs +61 -0
  232. package/src/touch-state.mjs +133 -0
  233. package/src/triggers-file.mjs +299 -39
  234. package/src/triggers.mjs +780 -38
  235. package/src/trust-paths.mjs +171 -0
  236. package/src/up.mjs +402 -53
  237. package/src/updates-cli.mjs +61 -0
  238. package/src/updates-look.mjs +127 -0
  239. package/src/updates-reconcile.mjs +182 -0
  240. package/src/updates-subscription-adapters.mjs +8 -0
  241. package/src/updates.mjs +252 -0
  242. package/src/valkey-auth.mjs +32 -9
  243. package/src/valkey-endpoint.mjs +14 -4
  244. package/src/wait-for.mjs +1 -1
  245. package/src/wait-state.mjs +62 -3
  246. package/src/watch-board.mjs +38 -0
  247. package/src/watch-closer.mjs +1 -1
  248. package/src/watch-contract.mjs +102 -0
  249. package/src/watch-decision.mjs +78 -0
  250. package/src/watch-drainer.mjs +14 -0
  251. package/src/watch-issue-adapter.mjs +44 -0
  252. package/src/watch-issue.mjs +111 -0
  253. package/src/watch-record.mjs +10 -0
  254. package/src/watch-runtime.mjs +167 -0
  255. package/src/watch-store.mjs +183 -0
  256. package/src/watch-trace.mjs +34 -0
  257. package/src/workspace-checkpoint.mjs +1750 -0
  258. package/src/zero-touch-fixes.mjs +99 -0
  259. package/src/zero-touch.mjs +225 -0
  260. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,604 @@
1
+ /**
2
+ * The operator console's audit log (issue #640; INT-CONSOLE-AUDIT-LOG). One JSON row per line in
3
+ * `PI_LOGS_DIR/console-audit/YYYY-MM.jsonl` (the UTC month of the row's `at`), directory 0700, files 0600, appended with
4
+ * O_APPEND. A worker subpath (`@edgehero/pi-dispatch/console-audit`) and not an admin module, because doctor verifies the
5
+ * chain and the worker cannot import the admin.
6
+ *
7
+ * TAMPER-EVIDENT, NOT TAMPER-PROOF. Every row carries `seq` (one more than the row before it) and `prev` (sha256 hex of
8
+ * the previous row's raw line, without its newline; `null` only for the very first row ever written). Changing,
9
+ * removing or moving a row breaks the link of the row AFTER it, which `verifyConsoleAudit` names. Named residuals: the
10
+ * account that runs the console can rewrite the whole chain from any row on (nothing here is signed by a key it does not
11
+ * hold), and a change to the LAST row has no successor to break, so it shows only once the next row is written.
12
+ *
13
+ * The head is recovered from the newest month file on EVERY append, not cached after the first: `pi-dispatch-console
14
+ * token add` (a separate process) appends to the same chain while `serve` runs, and a cached head in `serve` would then
15
+ * fork the chain at its next row. The tail read is bounded (`TAIL_BYTES`), so a flood of refusals costs a constant read
16
+ * per row, not one that grows with the file. The head read and the write happen under a short append lock
17
+ * (`APPEND_LOCK`, created exclusively with a token of its own, removed in `finally` only while it still holds that token), so `serve` and a CLI command appending at once take turns
18
+ * instead of both writing a row with the same `seq` and `prev`. A lock whose holder pid is dead is taken over; a lock
19
+ * still held after `LOCK_WAIT_MS` makes the append throw, which refuses the write (see `withAppendLock`).
20
+ *
21
+ * Rows come from the console (`serve`), its CLI (actor role `shell`) and, since issue #642, the panel: the `/dispatch` TUI
22
+ * and the pi extension's tools write theirs as actor role `panel` (`admin/src/audited-write.mjs`). The role is an escaped
23
+ * string like the name, not a closed set. A write row may also carry `kind` (which write), `reserve` (micro-dollars a
24
+ * paid action holds) and `count` (a bulk action's size), all optional: a row without them verifies as it always did.
25
+ *
26
+ * ACROSS VERSIONS (issue #761): the writer writes only the actions and outcomes this build lists, and the verifier links
27
+ * any row whose action and outcome have their shape (`CONSOLE_AUDIT_ACTION_RE`, `CONSOLE_AUDIT_OUTCOME_RE`), so a chain
28
+ * a newer console wrote into is still intact here, and an intact chain counts the rows with a value it does not know as
29
+ * `unknown`. A row carries no version: a version an older verifier refused would bring back the false "chain is broken"
30
+ * this rule removes. Keys a row holds beyond the ones judged here are not judged, as before.
31
+ *
32
+ * A row is mirrored to Valkey (`console:audit`, LPUSH then LTRIM, the `alloc:log` precedent in `allocation.mjs`) as a
33
+ * VIEW: the file is the record, and a mirror that fails loses nothing the file does not hold.
34
+ */
35
+ import { createHash, randomBytes } from "node:crypto";
36
+ import * as nodeFs from "node:fs";
37
+ import { join } from "node:path";
38
+ import { escapeControls } from "./projects.mjs";
39
+
40
+ export const CONSOLE_AUDIT_DIR = "console-audit";
41
+ export const CONSOLE_AUDIT_KEY = "console:audit";
42
+ export const CONSOLE_AUDIT_ACTIONS = Object.freeze(["login", "login-failed", "login-code", "logout", "refused", "write", "write-applied", "token-added", "token-revoked", "token-rotated", "sessions-ended", "serve-started", "login-device", "device-remembered", "device-ended"]);
43
+ export const CONSOLE_AUDIT_OUTCOMES = Object.freeze(["ok", "refused", "failed"]);
44
+ // A string field's cap, in code points, AFTER its controls are written out: a row stays small enough for the bounded tail
45
+ // read below whatever a caller hands in (a Host header, a refusal reason), and no field can bury the others.
46
+ export const CONSOLE_AUDIT_FIELD_MAX = 200;
47
+ // A write row's optional action name (issue #642: the console's action table and the panel's own writes), one short
48
+ // lowercase word with dashes: a closed charset rather than an escaped free string, so a reader can group rows by it and
49
+ // no caller can carry text in it. `action` itself stays the closed vocabulary above; `kind` only says which write.
50
+ export const CONSOLE_AUDIT_KIND_RE = /^[a-z0-9-]{1,40}$/;
51
+ // The SHAPE of an action and of an outcome (issue #761): what every value either list above has ever held, and what a
52
+ // value a later release adds must keep (a test holds every member to it). The writer stays closed (`auditRowBody` writes
53
+ // only the lists' members); the verifier links a row whose action or outcome it does not know but that has this shape,
54
+ // and counts it as `unknown`: such a row is shaped and linked, which is what a newer console writes, so it is not
55
+ // damage. Refusing it, as the verifier did before, reported an intact chain as broken at the first row a newer console
56
+ // wrote, and the older console then said "a row was changed" about a row nobody changed. Letter first, like every value
57
+ // so far, and `kind`'s cap of 40.
58
+ export const CONSOLE_AUDIT_ACTION_RE = /^[a-z][a-z0-9-]{0,39}$/;
59
+ export const CONSOLE_AUDIT_OUTCOME_RE = /^[a-z][a-z0-9-]{0,39}$/;
60
+ // Only names of this shape are the log's: the reaper and the verifier never touch or read anything else an operator put
61
+ // in the directory.
62
+ const MONTH_FILE_RE = /^\d{4}-\d{2}\.jsonl$/;
63
+ // The bounded read of a file's end, for its last line. A row is at most ten capped fields of 200 code points (4 bytes
64
+ // each in UTF-8 at worst) plus its keys, far under this.
65
+ const TAIL_BYTES = 65536;
66
+ // The append lock, beside the month files (the reaper and the verifier only look at month-file names).
67
+ export const APPEND_LOCK = ".append.lock";
68
+ const LOCK_WAIT_MS = 2000;
69
+ const LOCK_PAUSE = new Int32Array(new SharedArrayBuffer(4));
70
+
71
+ function processAlive(pid) {
72
+ try {
73
+ process.kill(pid, 0);
74
+ return true;
75
+ } catch (err) {
76
+ return err?.code === "EPERM";
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Hold `dir`'s append lock while `fn` runs. Sync, like the append it guards; waits in short sleeps, never spins hot.
82
+ *
83
+ * The lock file holds its holder's pid and a random token (`<pid> <hex>`), and a lock is STALE only when that pid is dead
84
+ * (`alive`, the same rule as the console's single-instance lock). Never by age: an age is the filesystem's mtime against
85
+ * this process's clock, two clocks, and a skew between them (or the clock-shifted CI run) would make every live lock look
86
+ * stale and let two writers in at once, which is the fork this lock exists to prevent. The audit directory is local to
87
+ * the host whose console writes it, so a pid names a process here. A lock still held, or still empty (a writer between
88
+ * its create and its pid), after `LOCK_WAIT_MS` makes the append throw, which refuses the write it was recording.
89
+ *
90
+ * The token is what makes a removal safe. Two writers can both judge the same dead lock: the first removes it and
91
+ * creates its own, and a second that removed by path would then remove that FRESH lock and both would append with the
92
+ * same `seq` and `prev`. So a takeover re-reads the lock just before it removes it and removes it only while it still
93
+ * holds exactly the stale text that was judged dead, and the `finally` removes the lock only while it still holds this
94
+ * writer's own token. Named residual: the window between that re-read and the unlink (no portable compare-and-unlink
95
+ * exists on a path), in which a writer that judged the same dead lock can create its fresh one and lose it to this
96
+ * unlink. Two takers have to read the same dead text and interleave inside that window, after a holder crashed.
97
+ */
98
+ function withAppendLock(dir, fs, fn, alive) {
99
+ const lock = join(dir, APPEND_LOCK);
100
+ const token = `${process.pid} ${randomBytes(8).toString("hex")}\n`;
101
+ const readLock = () => {
102
+ try {
103
+ return fs.readFileSync(lock, "utf8");
104
+ } catch (err) {
105
+ if (err?.code === "ENOENT") return null;
106
+ throw err;
107
+ }
108
+ };
109
+ const started = Date.now();
110
+ for (;;) {
111
+ try {
112
+ fs.writeFileSync(lock, token, { flag: "wx", mode: 0o600 });
113
+ break;
114
+ } catch (err) {
115
+ if (err?.code !== "EEXIST") throw err;
116
+ }
117
+ const stale = readLock();
118
+ if (stale === null) continue;
119
+ const holder = Number.parseInt(stale, 10);
120
+ if (Number.isSafeInteger(holder) && holder > 0 && !alive(holder)) {
121
+ // Only the text judged dead goes: another writer may have taken it over since, and its fresh lock is live.
122
+ if (readLock() === stale) {
123
+ try {
124
+ fs.unlinkSync(lock);
125
+ } catch (err) {
126
+ if (err?.code !== "ENOENT") throw err;
127
+ }
128
+ }
129
+ continue;
130
+ }
131
+ if (Date.now() - started > LOCK_WAIT_MS) {
132
+ throw Object.assign(new Error(`the console audit log is locked by another writer (${lock}${Number.isSafeInteger(holder) ? `, pid ${holder}` : ""}); try again, or remove the file if no console command is running`), { code: "EAUDITLOCKED" });
133
+ }
134
+ Atomics.wait(LOCK_PAUSE, 0, 0, 10);
135
+ }
136
+ try {
137
+ return fn();
138
+ } finally {
139
+ try {
140
+ // A lock that no longer holds our token was taken over (we were judged dead, or removed by hand): not ours.
141
+ if (readLock() === token) fs.unlinkSync(lock);
142
+ } catch {
143
+ // Already gone or unreadable: nothing of ours to remove.
144
+ }
145
+ }
146
+ }
147
+
148
+ function sha256(text) {
149
+ return createHash("sha256").update(text).digest("hex");
150
+ }
151
+
152
+ /**
153
+ * A string field as it is stored: controls and the other characters that change what a reader SEES written out as
154
+ * `\u{XXXX}` (the worker's `escapeControls`, the same class the admin panel scrubs), then capped. Written out, not
155
+ * deleted: an audit row that silently lost a character would hide exactly what a forged Host header tried to smuggle.
156
+ */
157
+ function auditString(value, field) {
158
+ if (typeof value !== "string") throw new Error(`console audit row: ${field} must be a string`);
159
+ const text = escapeControls(value);
160
+ const points = [...text];
161
+ return points.length > CONSOLE_AUDIT_FIELD_MAX ? points.slice(0, CONSOLE_AUDIT_FIELD_MAX).join("") : text;
162
+ }
163
+
164
+ /** Watch rows name the other participant without admitting an arbitrary peer object into the journal. */
165
+ export function consoleAuditDriver(value) {
166
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join() !== "name,role,session" || !/^[a-z0-9][a-z0-9._-]{0,31}$/.test(value.name ?? "") || value.role !== "operator" || !/^[a-f0-9]{12}$/.test(value.session ?? "")) return null;
167
+ return { name: value.name, role: value.role, session: value.session };
168
+ }
169
+ const WATCH_AUDIT_KINDS = new Set(["sandbox-watch-opening", "sandbox-watch-start", "sandbox-watch-end"]);
170
+ /** The row as stored, from a caller's input, or a throw naming the field that is wrong (the caller refuses its write). */
171
+ function auditRowBody(input) {
172
+ if (input === null || typeof input !== "object") throw new Error("console audit row must be an object");
173
+ const { actor, action, outcome } = input;
174
+ if (!CONSOLE_AUDIT_ACTIONS.includes(action)) throw new Error(`console audit row: unknown action ${JSON.stringify(escapeControls(String(action)))} (one of ${CONSOLE_AUDIT_ACTIONS.join(", ")})`);
175
+ if (!CONSOLE_AUDIT_OUTCOMES.includes(outcome)) throw new Error(`console audit row: unknown outcome ${JSON.stringify(escapeControls(String(outcome)))} (one of ${CONSOLE_AUDIT_OUTCOMES.join(", ")})`);
176
+ if (actor === null || typeof actor !== "object") throw new Error("console audit row: actor must be an object with name, role and session");
177
+ const body = {
178
+ actor: {
179
+ name: auditString(actor.name, "actor.name"),
180
+ role: auditString(actor.role, "actor.role"),
181
+ session: actor.session === null || actor.session === undefined ? null : auditString(actor.session, "actor.session"),
182
+ },
183
+ action,
184
+ };
185
+ const optional = (field) => (input[field] === undefined || input[field] === null ? {} : { [field]: auditString(input[field], field) });
186
+ const absent = (field) => input[field] === undefined || input[field] === null;
187
+ if (!absent("kind") && !(typeof input.kind === "string" && CONSOLE_AUDIT_KIND_RE.test(input.kind))) throw new Error("console audit row: kind must be 1 to 40 of a-z, 0-9 and -");
188
+ if (!absent("reserve") && !(Number.isSafeInteger(input.reserve) && input.reserve >= 0)) throw new Error("console audit row: reserve must be a non-negative whole number of micro-dollars");
189
+ if (!absent("count") && !(Number.isSafeInteger(input.count) && input.count >= 1)) throw new Error("console audit row: count must be a positive whole number");
190
+ const number = (field) => (absent(field) ? {} : { [field]: input[field] });
191
+ const driver = consoleAuditDriver(input.driver);
192
+ if (WATCH_AUDIT_KINDS.has(input.kind) ? !driver : !absent("driver")) throw new Error("console audit row: driver is required only on watch attachment rows");
193
+ // In reading order: who, did what (and which write), to what, how it went, why, what it holds and how many, then the
194
+ // digests and the ticket that tie it to a change.
195
+ return { ...body, ...(absent("kind") ? {} : { kind: input.kind }), ...optional("target"), outcome, ...optional("reason"), ...number("reserve"), ...number("count"), ...optional("beforeDigest"), ...optional("afterDigest"), ...optional("ticket"), ...(driver ? { driver } : {}) };
196
+ }
197
+
198
+ /**
199
+ * Whether a parsed row's optional issue #642 fields, where present, are what `auditRowBody` writes: `kind` in its
200
+ * charset, `reserve` a non-negative safe integer, `count` a positive one. A row without them is a row from before them,
201
+ * and is fine.
202
+ */
203
+ function optionalFieldsWellFormed(row) {
204
+ if (row.kind !== undefined && !(typeof row.kind === "string" && CONSOLE_AUDIT_KIND_RE.test(row.kind))) return false;
205
+ if (row.reserve !== undefined && !(Number.isSafeInteger(row.reserve) && row.reserve >= 0)) return false;
206
+ if (row.count !== undefined && !(Number.isSafeInteger(row.count) && row.count >= 1)) return false;
207
+ return true;
208
+ }
209
+
210
+ /** Whether a parsed row's action or outcome is a string of `re`'s shape (issue #761). */
211
+ function shapedWord(value, re) {
212
+ return typeof value === "string" && re.test(value);
213
+ }
214
+
215
+ /** The `YYYY-MM.jsonl` names in `dir`, oldest first; [] when the directory does not exist. Other errors throw. */
216
+ function monthFiles(dir, fs) {
217
+ let names;
218
+ try {
219
+ names = fs.readdirSync(dir);
220
+ } catch (err) {
221
+ if (err?.code === "ENOENT") return [];
222
+ throw err;
223
+ }
224
+ return names.filter((n) => MONTH_FILE_RE.test(n)).sort();
225
+ }
226
+
227
+ /** The last non-empty line of a file, read from its end, or null for an empty file. */
228
+ function lastLine(path, fs) {
229
+ const fd = fs.openSync(path, "r");
230
+ try {
231
+ const size = fs.fstatSync(fd).size;
232
+ const length = Math.min(size, TAIL_BYTES);
233
+ const buf = Buffer.alloc(length);
234
+ let got = 0;
235
+ while (got < length) {
236
+ const n = fs.readSync(fd, buf, got, length - got, size - length + got);
237
+ if (n === 0) break;
238
+ got += n;
239
+ }
240
+ const lines = buf.subarray(0, got).toString("utf8").split("\n").filter((l) => l !== "");
241
+ if (lines.length === 0) return null;
242
+ // A tail read that started mid-file and holds one partial line cannot vouch for it: refuse rather than chain a
243
+ // fragment.
244
+ if (lines.length === 1 && size > length) throw new Error(`the console audit file ${path} ends in a line longer than ${TAIL_BYTES} bytes; run \`pi-dispatch-console audit verify\``);
245
+ return lines[lines.length - 1];
246
+ } finally {
247
+ fs.closeSync(fd);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The chain's head on disk: `{ seq, hash, month }`, seq 0 and hash null when no row exists. Read from the newest month
253
+ * file's last line, so a row in a new month links to the old month's last. A newest file that exists but is empty is
254
+ * skipped for the one before it (a crash between create and write leaves one).
255
+ */
256
+ function readHead(dir, fs) {
257
+ const files = monthFiles(dir, fs);
258
+ for (let i = files.length - 1; i >= 0; i--) {
259
+ const line = lastLine(join(dir, files[i]), fs);
260
+ if (line === null) continue;
261
+ let row;
262
+ try {
263
+ row = JSON.parse(line);
264
+ } catch {
265
+ row = null;
266
+ }
267
+ if (row === null || typeof row !== "object" || !Number.isInteger(row.seq) || row.seq < 1) {
268
+ throw new Error(`the last line of the console audit file ${join(dir, files[i])} is not an audit row, so the next row cannot link to it; run \`pi-dispatch-console audit verify\` and move the damaged file aside`);
269
+ }
270
+ return { seq: row.seq, hash: sha256(line), month: files[i].slice(0, 7) };
271
+ }
272
+ return { seq: 0, hash: null, month: null };
273
+ }
274
+
275
+ /** Refuse a directory or file that is a symlink: the console's own account writes here, and a link would aim it elsewhere. */
276
+ function refuseLink(path, fs, what) {
277
+ let st;
278
+ try {
279
+ st = fs.lstatSync(path);
280
+ } catch (err) {
281
+ if (err?.code === "ENOENT") return;
282
+ throw err;
283
+ }
284
+ if (st.isSymbolicLink()) throw new Error(`the console audit ${what} ${path} is a symlink; replace it with a real ${what} owned by this account`);
285
+ }
286
+
287
+ /**
288
+ * The audit writer. `append(row)` is SYNC and THROWS on any failure (a bad row, a full disk, a damaged head): the caller
289
+ * refuses the write it was about to make, because a change the record does not hold is the outcome this log exists to
290
+ * prevent. Returns the stored row. `head()` is the chain's head on disk, `{ seq, hash }`.
291
+ */
292
+ export function makeConsoleAudit({ logsDir, fs = nodeFs, now = () => Date.now(), alive = processAlive }) {
293
+ const dir = join(logsDir, CONSOLE_AUDIT_DIR);
294
+ return {
295
+ dir,
296
+ append(input) {
297
+ const body = auditRowBody(input);
298
+ refuseLink(dir, fs, "directory");
299
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
300
+ return withAppendLock(dir, fs, () => {
301
+ const head = readHead(dir, fs);
302
+ const at = new Date(now()).toISOString();
303
+ // A clock that stepped back across a month boundary still appends to the NEWEST file: the chain is read file by
304
+ // file in name order, and a row written into an older file would sit before rows it follows.
305
+ const month = head.month !== null && head.month > at.slice(0, 7) ? head.month : at.slice(0, 7);
306
+ const row = { at, seq: head.seq + 1, prev: head.hash, ...body };
307
+ const path = join(dir, `${month}.jsonl`);
308
+ refuseLink(path, fs, "file");
309
+ fs.appendFileSync(path, `${JSON.stringify(row)}\n`, { flag: "a", mode: 0o600 });
310
+ return row;
311
+ }, alive);
312
+ },
313
+ head() {
314
+ const { seq, hash } = readHead(dir, fs);
315
+ return { seq, hash };
316
+ },
317
+ };
318
+ }
319
+
320
+ /**
321
+ * Walk the chain, oldest file first: `{ ok: true, rows, startsAt }` or `{ ok: false, rows, broken: { file, line, reason } }`
322
+ * with the FIRST broken link (`line` is 1-based within `file`; `rows` counts the rows read before it). The oldest
323
+ * surviving row's `prev` cannot be checked once the reaper took the file before it, so it is not: a chain that starts
324
+ * there is reported as `startsAt: { file, seq }`, not as broken. `startsAt` is null when the chain starts at its very
325
+ * first row (seq 1, prev null). Never throws: an unreadable directory or file is the broken link.
326
+ *
327
+ * The intact shape also carries `unknown: n` when n of its `rows` have an action or outcome this build does not know but
328
+ * of the shape the console writes (issue #761: shaped and linked, which a newer console writes), and only then. A broken
329
+ * result never carries it: the row before a break whose line shows no matching `prev` passed its own checks and is
330
+ * counted in `rows`, yet nothing vouches for it, so a count there would vouch for a row the chain does not, and no
331
+ * reader shows it for a broken chain.
332
+ *
333
+ * `withLast` (issue #737, the console's audit screen) adds three fields to both shapes, for a reader that marks rows by
334
+ * their place (the place is the one thing that tells two rows apart reliably: a damaged line has no seq, and a tampered
335
+ * file can hold one seq twice):
336
+ *
337
+ * - `last: { file, line, seq }`, the last row the chain VOUCHES for (null when none). A row is vouched for only by
338
+ * the line after it, when that line's `prev` matches the row's hash. On an intact chain `last` is the newest row
339
+ * walked, though no line vouches for that one yet (none follows it). At a break whose line does not show a
340
+ * `prev` that matches the row before it (it does not parse, its `prev` is not a hash or null, or its `prev` is
341
+ * another hash), `last` is the row before THAT one: an edit to the row before the break leaves that row's own line
342
+ * well formed and moves only its hash, so a line after it that does not show a matching `prev` cannot tell an
343
+ * edited row from an intact one, and the row before the break is not vouched for. That is judged before the line's
344
+ * action, outcome and fields are, so an unknown action does not hide it. Only a line whose `prev` matched and that
345
+ * then fails another check (its seq, action, outcome or fields) is damaged alone, and the row before it stays
346
+ * vouched for. A file that cannot be read stops the walk, and the row before it stays as the newest row read.
347
+ * - `suspect: { file, line, seq }`, that row before a break whose line does not show a matching `prev`, which the
348
+ * chain walked but cannot vouch for, or null for every other result.
349
+ * - `files: [{ file, seq, rows }]`, oldest first: each month file the walk passed a row in, the seq of its first row
350
+ * (always its line 1: a line that does not pass ends the walk) and how many rows the walk passed in it. Row `s` of an
351
+ * entry is then at line `s - seq + 1` of its file, so the screen can open the page that holds a row by its number
352
+ * without walking every row written since (it reads that place and checks the seq there, falling back to the walk
353
+ * when the file no longer agrees). One entry a month, so it stays small.
354
+ *
355
+ * Opt-in, so the result doctor, the CLI and the tests compare whole stays as it was.
356
+ */
357
+ export function verifyConsoleAudit({ logsDir, fs = nodeFs, withLast = false }) {
358
+ const dir = join(logsDir, CONSOLE_AUDIT_DIR);
359
+ let last = null; // { file, line, seq } of the last good row, for `withLast`
360
+ let beforeLast = null; // the good row before `last`, which a line that does not link to `last` leaves as the last vouched for
361
+ const index = []; // { file, seq, rows } per month file the walk passed a row in, for `withLast`
362
+ let unknown = 0; // rows that passed every check with an action or outcome this build does not know (issue #761)
363
+ // `unknown` only on an intact chain and only when some row has it (issue #761), so a chain of known rows answers
364
+ // exactly as it always did (doctor, the CLI and the tests compare this result whole), and a broken one never counts
365
+ // the row before its break, which nothing vouches for.
366
+ const shaped = (result, unlinked = false) => {
367
+ const counted = result.ok && unknown > 0 ? { ...result, unknown } : result;
368
+ if (!withLast) return counted;
369
+ return unlinked ? { ...counted, last: beforeLast, suspect: last, files: index } : { ...counted, last, suspect: null, files: index };
370
+ };
371
+ let files;
372
+ try {
373
+ files = monthFiles(dir, fs);
374
+ } catch (err) {
375
+ return shaped({ ok: false, rows: 0, broken: { file: null, line: 0, reason: `the audit directory ${dir} cannot be read (${err?.code ?? "error"})` } });
376
+ }
377
+ let rows = 0;
378
+ let prior = null; // { seq, hash } of the last good row
379
+ let startsAt = null;
380
+ for (const file of files) {
381
+ let i = -1;
382
+ try {
383
+ for (const line of fileLines(fs, join(dir, file))) {
384
+ i += 1;
385
+ let row;
386
+ try {
387
+ row = JSON.parse(line);
388
+ } catch {
389
+ row = undefined;
390
+ }
391
+ // Whether this line vouches for the row before it (`withLast`, see above), decided before anything else about
392
+ // the line is judged. `prior.hash` is a sha256, so only a well-formed `prev` can equal it. The first row walked
393
+ // has no row before it to vouch for.
394
+ const linked = prior !== null && row?.prev === prior.hash;
395
+ const broken = (reason) => shaped({ ok: false, rows, broken: { file, line: i + 1, reason } }, !linked);
396
+ if (row === undefined) return broken("the line is not a JSON row (damaged, or written by something else)");
397
+ if (row === null || typeof row !== "object" || Array.isArray(row) || !Number.isInteger(row.seq) || row.seq < 1 || !(row.prev === null || (typeof row.prev === "string" && /^[0-9a-f]{64}$/.test(row.prev)))) {
398
+ return broken("the row has no valid seq and prev");
399
+ }
400
+ // Known, or of the shape a newer console's value keeps (issue #761): linked either way, and an unknown one is
401
+ // counted below once the row passed every other check. Judged after `linked`, so an edited unknown row is still
402
+ // named by the broken link of the row after it, exactly as an edited known row is.
403
+ const known = CONSOLE_AUDIT_ACTIONS.includes(row.action) && CONSOLE_AUDIT_OUTCOMES.includes(row.outcome);
404
+ if (!known && !(shapedWord(row.action, CONSOLE_AUDIT_ACTION_RE) && shapedWord(row.outcome, CONSOLE_AUDIT_OUTCOME_RE))) {
405
+ return broken("the row's action or outcome is not of the shape the console writes");
406
+ }
407
+ if (WATCH_AUDIT_KINDS.has(row.kind) ? !consoleAuditDriver(row.driver) : row.driver !== undefined) return broken("the row's watch driver is not one the console writes");
408
+ if (!optionalFieldsWellFormed(row)) return broken("the row's kind, reserve or count is not one the console writes");
409
+ if (prior === null) {
410
+ if (row.prev === null) {
411
+ if (row.seq !== 1) return broken(`the first row has no prev but seq ${row.seq}; the chain's start was changed`);
412
+ } else {
413
+ // The rows before it were reaped (retention), or removed: the two look alike, so the start is reported.
414
+ startsAt = { file, seq: row.seq };
415
+ }
416
+ } else {
417
+ if (!linked) return broken(`prev does not match the row before it (seq ${prior.seq}): a row was changed, removed or moved`);
418
+ if (row.seq !== prior.seq + 1) return broken(`seq ${row.seq} follows seq ${prior.seq}`);
419
+ }
420
+ prior = { seq: row.seq, hash: sha256(line) };
421
+ beforeLast = last;
422
+ last = { file, line: i + 1, seq: row.seq };
423
+ if (index.length === 0 || index[index.length - 1].file !== file) index.push({ file, seq: row.seq, rows: 0 });
424
+ index[index.length - 1].rows += 1;
425
+ rows += 1;
426
+ if (!known) unknown += 1;
427
+ }
428
+ } catch (err) {
429
+ return shaped({ ok: false, rows, broken: { file, line: i + 1, reason: `the file cannot be read (${err?.code ?? "error"})` } });
430
+ }
431
+ }
432
+ return shaped({ ok: true, rows, startsAt });
433
+ }
434
+
435
+ /** The most rows one `readConsoleAudit` call returns, whatever it is asked for. */
436
+ export const CONSOLE_AUDIT_READ_MAX = 500;
437
+
438
+ /**
439
+ * An audit cursor, `YYYY-MM:<line>`: the month file's name without `.jsonl` and a 1-based line number in it (see
440
+ * `readConsoleAudit`). The line is at most 16 digits and must be a safe integer.
441
+ */
442
+ export const CONSOLE_AUDIT_CURSOR_RE = /^(\d{4}-\d{2}):([1-9]\d{0,15})$/;
443
+
444
+ /**
445
+ * The newest audit rows first, for the console's audit screen (issue #641): `{ rows, next }`, or `{ rows: [], next: null,
446
+ * unreadable }` when the directory cannot be listed or `before` is not a cursor. Never throws.
447
+ *
448
+ * Each line is parsed and returned as it is stored. A line that is not an audit row (not JSON, not an object, no
449
+ * positive integer `seq`) is NOT skipped: it comes back as `{ broken: true, file, line }` (`line` 1-based in `file`), in
450
+ * its place, because an audit screen that quietly drops a damaged line hides exactly what `verifyConsoleAudit` exists to
451
+ * name. A file that cannot be read (or stops being readable part-way) is one such entry with `line: 0`.
452
+ *
453
+ * THE CURSOR IS A PLACE IN THE FILES, NOT A ROW: `YYYY-MM:<line>` (`CONSOLE_AUDIT_CURSOR_RE`), the month file and the
454
+ * 1-based line an entry was read from (a read failure's entry sits one line past the last line read). `before` returns
455
+ * only entries above it in file order (an older month file, or the same file at a lower line), and `next` is the last
456
+ * returned entry's cursor, or null when nothing older exists. Every entry has its own place, so a page can end on any
457
+ * one of a run of damaged lines and the next page starts at the line under it. A place stays put because the files only
458
+ * ever grow at the end of the newest one (the reaper takes whole files). The seq was the cursor once, with a damaged
459
+ * line at a half step beside the good row before it; it was refuted, because two damaged lines in a row shared one half
460
+ * step (a page ending on the first skipped the second) and a file's leading damaged line shared it with the previous
461
+ * file's trailing one.
462
+ *
463
+ * BOUNDED MEMORY: files are walked newest first, each through the chunked `fileLines`, and only the newest `limit`
464
+ * qualifying entries of a file are held (a ring buffer), so a month file of any size costs `limit` entries of memory. A
465
+ * file newer than the cursor is not opened, and the cursor's own file is read only up to the cursor's line.
466
+ */
467
+ export function readConsoleAudit({ logsDir, limit = 100, before = null, fs = nodeFs, positions = false } = {}) {
468
+ const cap = Math.min(CONSOLE_AUDIT_READ_MAX, Math.max(1, Number.isFinite(limit) ? Math.floor(limit) : 100));
469
+ // `positions` (issue #737) adds each entry's own place, `YYYY-MM:<line>`, parallel to `rows`: the console's audit screen
470
+ // compares it with `verifyConsoleAudit`'s `broken` and `last` to mark a row damaged, not verified or not yet checked,
471
+ // and a row's id on the page is built from it when its seq cannot be one. Opt-in, so the shape callers compare whole is
472
+ // unchanged.
473
+ const unread = (unreadable) => (positions ? { rows: [], next: null, positions: [], unreadable } : { rows: [], next: null, unreadable });
474
+ let bound = null; // { file, line }: entries strictly above this place
475
+ if (before !== null && before !== undefined) {
476
+ const m = typeof before === "string" ? CONSOLE_AUDIT_CURSOR_RE.exec(before) : null;
477
+ if (m === null || !Number.isSafeInteger(Number(m[2]))) return unread("the cursor is not an audit position (YYYY-MM:line)");
478
+ bound = { file: `${m[1]}.jsonl`, line: Number(m[2]) };
479
+ }
480
+ const dir = join(logsDir, CONSOLE_AUDIT_DIR);
481
+ let files;
482
+ try {
483
+ files = monthFiles(dir, fs);
484
+ } catch (err) {
485
+ return unread(`the audit directory cannot be read (${err?.code ?? "error"})`);
486
+ }
487
+ const out = []; // newest first, each { entry, pos }
488
+ let more = false;
489
+ for (let f = files.length - 1; f >= 0 && !more; f--) {
490
+ const file = files[f];
491
+ if (bound !== null && file > bound.file) continue;
492
+ // The first line that is NOT this page's: the cursor's own line in its file, none in an older one.
493
+ const stop = bound !== null && file === bound.file ? bound.line : Infinity;
494
+ const want = cap - out.length;
495
+ const ring = new Array(want);
496
+ let held = 0; // qualifying entries seen in this file
497
+ const keep = (entry, line) => {
498
+ ring[held % want] = { entry, pos: `${file.slice(0, 7)}:${line}` };
499
+ held += 1;
500
+ };
501
+ let i = 0;
502
+ try {
503
+ for (const line of fileLines(fs, join(dir, file))) {
504
+ i += 1;
505
+ if (i >= stop) break;
506
+ let row;
507
+ try {
508
+ row = JSON.parse(line);
509
+ } catch {
510
+ row = null;
511
+ }
512
+ if (row === null || typeof row !== "object" || Array.isArray(row) || !Number.isInteger(row.seq) || row.seq < 1) keep({ broken: true, file, line: i }, i);
513
+ else keep(row, i);
514
+ }
515
+ } catch (err) {
516
+ if (i + 1 < stop) keep({ broken: true, file, line: 0, reason: err?.code ?? "error" }, i + 1);
517
+ }
518
+ const n = Math.min(held, want);
519
+ for (let k = 1; k <= n; k++) out.push(ring[(held - k) % want]);
520
+ if (held > want) more = true;
521
+ // A page that filled exactly at the end of a file: an older file may still hold rows, so say there is more.
522
+ else if (out.length === cap && f > 0) more = true;
523
+ }
524
+ const page = { rows: out.map((x) => x.entry), next: more && out.length > 0 ? out[out.length - 1].pos : null };
525
+ return positions ? { ...page, positions: out.map((x) => x.pos) } : page;
526
+ }
527
+
528
+ /**
529
+ * The lines of `path`, read a chunk at a time, never the whole file at once: a month file can grow past what one
530
+ * string holds (V8's limit is about 512 MiB), and verify must still walk it rather than call it broken. A line is cut
531
+ * at the newline BYTE, so a multi-byte character split across two chunks is decoded whole. The newline after the last
532
+ * row ends nothing; a file cut mid-row yields its fragment as a last line, which the caller judges.
533
+ */
534
+ function* fileLines(fs, path, chunkBytes = 1 << 20) {
535
+ const fd = fs.openSync(path, "r");
536
+ try {
537
+ const buf = Buffer.alloc(chunkBytes);
538
+ let carry = [];
539
+ for (;;) {
540
+ const n = fs.readSync(fd, buf, 0, chunkBytes, null);
541
+ if (n === 0) break;
542
+ let start = 0;
543
+ for (let at = buf.indexOf(10, 0); at !== -1 && at < n; at = buf.indexOf(10, at + 1)) {
544
+ carry.push(Buffer.from(buf.subarray(start, at)));
545
+ yield Buffer.concat(carry).toString("utf8");
546
+ carry = [];
547
+ start = at + 1;
548
+ }
549
+ if (start < n) carry.push(Buffer.from(buf.subarray(start, n)));
550
+ }
551
+ if (carry.length > 0) yield Buffer.concat(carry).toString("utf8");
552
+ } finally {
553
+ fs.closeSync(fd);
554
+ }
555
+ }
556
+
557
+ /**
558
+ * The reaper of the audit files (a sibling of `makeAllocationLogReaper`), on `PI_CONSOLE_AUDIT_RETENTION_DAYS`, which
559
+ * inherits `PI_LOG_RETENTION_DAYS` when unset (issue #761: its own setting, so shortening the run history for privacy
560
+ * does not shorten the security trail; the caller passes the resolved days): a `YYYY-MM.jsonl` file whose last write
561
+ * (mtime) is older than the retention goes, 0 keeps everything, nothing of another name is touched. The NEWEST file is never reaped, however old: it holds the chain's
562
+ * head, and without it the next row would start a new chain at seq 1 that `verifyConsoleAudit` cannot tell from a
563
+ * forged one. Never throws.
564
+ */
565
+ export function makeConsoleAuditReaper({ logsDir, retentionDays, fs = nodeFs, log = () => {}, now = () => Date.now() }) {
566
+ const dir = join(logsDir, CONSOLE_AUDIT_DIR);
567
+ return function reapConsoleAudit() {
568
+ if (retentionDays === 0) return;
569
+ let names;
570
+ try {
571
+ names = monthFiles(dir, fs);
572
+ } catch (err) {
573
+ log("console_audit_reaper_skipped", { reason: err?.code ?? "error" });
574
+ return;
575
+ }
576
+ const cutoff = now() - retentionDays * 86400000;
577
+ for (const name of names.slice(0, -1)) {
578
+ try {
579
+ const st = fs.lstatSync(join(dir, name));
580
+ if (st.isFile() && st.mtimeMs < cutoff) {
581
+ fs.unlinkSync(join(dir, name));
582
+ log("reaped_console_audit", { file: name });
583
+ }
584
+ } catch (err) {
585
+ log("console_audit_reaper_skipped", { file: name, reason: err?.code ?? "error" });
586
+ }
587
+ }
588
+ };
589
+ }
590
+
591
+ /**
592
+ * Best effort: the row onto `console:audit` (LPUSH, then LTRIM to `max`), for a screen that lists recent rows without
593
+ * reading files. Resolves true when both landed, false otherwise; never throws, and logs nothing (the caller has the
594
+ * row and knows the file holds it).
595
+ */
596
+ export async function mirrorRow(redis, row, max = 500) {
597
+ try {
598
+ await redis.lpush(CONSOLE_AUDIT_KEY, JSON.stringify(row));
599
+ await redis.ltrim(CONSOLE_AUDIT_KEY, 0, max - 1);
600
+ return true;
601
+ } catch {
602
+ return false;
603
+ }
604
+ }