@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,872 @@
1
+ import { constants as fsConstants, lstatSync as fsLstatSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { lstat as fsLstat, open as fsOpen } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { escapeControls } from "./projects.mjs";
5
+ import { makeProgressSink } from "./run-progress.mjs";
6
+ import { sanitizeJobId } from "./run-history.mjs";
7
+
8
+ /**
9
+ * The live transcript (issue #643, `DES-LIVE-TRANSCRIPT-STREAM`, `INT-LIVE-TRANSCRIPT-STREAM`): the worker tails the
10
+ * pi session file of a running job and publishes a redacted, compact copy of it to one Valkey stream per job, so an
11
+ * operator can watch the run from the console on any host and from the `/dispatch` panel.
12
+ *
13
+ * WHY THE WORKER AND NOT THE RUNNER. The runner is inside the container, and a container that could write to Valkey
14
+ * would be a new network path out of the box (`CONST-ISOLATION-CONTAINER-PER-JOB`). The worker already mounts the
15
+ * file; reading it from this side adds no mount and no route, and the job image is untouched.
16
+ *
17
+ * WHY A POLL AND NOT `fs.watch`. The file is a bind mount, and on Docker Desktop and Podman machine it crosses a VM:
18
+ * change events are dropped or delayed there, measured often enough that a watch would be a stream that sometimes
19
+ * stops. A `stat` every half second costs nothing next to a model call, and reads only what was appended.
20
+ *
21
+ * BEST EFFORT, BOUNDED, NEVER THROWING. Every Valkey call is bounded by `OP_TIMEOUT_MS` (the BullMQ connection queues
22
+ * a command against an unreachable server forever), every failure is counted and the batch dropped rather than
23
+ * buffered, and nothing here can fail or slow the job: a lost entry costs a line in a viewer, a stuck job costs money.
24
+ *
25
+ * Every published entry is redacted (redact-secrets.mjs), then has its control and bidi characters written out
26
+ * (`escapeText`: `escapeControls`, keeping real newlines and tabs), then is truncated by code points. Log lines carry the job id and counts only, never an entry's
27
+ * content (no-pii-in-logs).
28
+ */
29
+
30
+ /** The stream key's prefix; the key is this plus the sanitized job id, as `runs:rec:` is (run-mirror.mjs). */
31
+ export const LIVE_KEY_PREFIX = "run:live:";
32
+ /** The stream's cap, `XADD ... MAXLEN ~`: approximate, so Valkey trims whole nodes and the bound is a floor. */
33
+ export const LIVE_MAX_ENTRIES = 2000;
34
+ /** The key's lifetime while the run is live, set at the first write: a crashed worker cannot leave it forever. */
35
+ export const LIVE_TTL_LIVE_MS = 7 * 24 * 60 * 60 * 1000;
36
+ /** The key's lifetime after the `end` entry. */
37
+ export const LIVE_TTL_ENDED_MS = 24 * 60 * 60 * 1000;
38
+ /** Every `kind` an entry can carry; readers refuse anything else. */
39
+ export const LIVE_KINDS = Object.freeze(["user", "assistant", "tool-call", "tool-result", "steer", "compaction", "note", "reset", "skipped", "end"]);
40
+ /** The `outcome` an `end` entry carries: the run's outcome, or `unknown`. Never free text. */
41
+ export const LIVE_OUTCOMES = Object.freeze(["completed", "policy", "failed", "unknown"]);
42
+ /** The most code points a `text` carries; an `assistant` entry gets the larger one. */
43
+ export const LIVE_TEXT_MAX = 4096;
44
+ export const LIVE_ASSISTANT_TEXT_MAX = 8192;
45
+ /** A transcript line longer than this is skipped, never parsed: one tool result must not pin a megabyte per poll. */
46
+ export const LIVE_LINE_MAX_BYTES = 1024 * 1024;
47
+ /** The `tool` charset; anything else is published as `?`. */
48
+ export const LIVE_TOOL_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
49
+
50
+ /** The fixed text an unparseable line becomes. Never the raw bytes: the line is agent-written. */
51
+ export const UNPARSEABLE_NOTE = "a transcript line that is not JSON was left out";
52
+ /** Published once when the transcript file is no longer a regular file (a link or a FIFO swapped in): never read again. */
53
+ export const REFUSED_NOTE = "the transcript file was replaced by something other than a regular file, so it is no longer read";
54
+
55
+ /** One run's stream key. */
56
+ export const liveKeyFor = (jobId) => `${LIVE_KEY_PREFIX}${sanitizeJobId(jobId)}`;
57
+
58
+ /** Each Valkey call's bound. Short: a viewer can wait half a second longer, the job must not. */
59
+ const OP_TIMEOUT_MS = 1_000;
60
+ /** The whole of `finish`'s bound, final read and flush included, so a dead Valkey adds at most this to a run's end. */
61
+ const FINISH_TIMEOUT_MS = 3_000;
62
+ /**
63
+ * How many entries, and how many bytes of their field values (UTF-16, two per code unit: what they occupy in memory),
64
+ * may wait for a write. Past either bound the OLDEST are dropped and counted, never buffered: a viewer watching live
65
+ * needs the newest lines, and the `end` entry is never queued, so it is never the one dropped.
66
+ */
67
+ const PENDING_MAX = LIVE_MAX_ENTRIES;
68
+ const PENDING_MAX_BYTES = 8 * 1024 * 1024;
69
+ /** The most bytes `finish`'s final read takes, so a huge or sparse file cannot hold the run's end. */
70
+ export const LIVE_FINAL_READ_MAX_BYTES = 64 * 1024 * 1024;
71
+ /** How far past the publish time a pi timestamp may lie before the publish time is used instead. */
72
+ const TS_FUTURE_MS = 24 * 60 * 60 * 1000;
73
+ /** The most bytes one poll reads, so a burst is taken over several polls rather than in one allocation. */
74
+ const READ_CHUNK_BYTES = 4 * 1024 * 1024;
75
+ /** How much of the first line is kept to notice a rewrite. The header is one short line. */
76
+ const FIRST_LINE_PROBE_BYTES = 4096;
77
+
78
+ /** The file the container sees, inside the job's own session directory. */
79
+ export const LIVE_FILE_NAME = "current.jsonl";
80
+
81
+ /**
82
+ * Stage the live transcript file for a job that has no resume transcript: `<jobDir>/session/current.jsonl`, the
83
+ * directory 0700 and the file empty, the SAME layout `resolveSession` stages for a resume job (session-store.mjs).
84
+ * The same directory on purpose: it is mounted at the runner's existing `/session` path, the sandbox retention
85
+ * already removes it before it keeps a run (sandbox-store.mjs), and it is deleted with the job directory. There is
86
+ * no store key, nothing is promoted and the run record gains nothing: whether a job RESUMES is still keyed on
87
+ * `prepared.session` alone.
88
+ *
89
+ * Empty, not absent: pi then takes its empty-file branch and writes its own header at this path (the reason
90
+ * session-store.mjs gives). Returns `{ hostDir }`, or null when it could not be staged (the job then runs as every
91
+ * job did before this feature, with no file and no stream). Never throws.
92
+ */
93
+ export function stageLiveFile(jobDir, { fs = { mkdirSync, writeFileSync } } = {}) {
94
+ if (typeof jobDir !== "string" || jobDir === "") return null;
95
+ try {
96
+ const hostDir = join(jobDir, "session");
97
+ fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 });
98
+ fs.writeFileSync(join(hostDir, LIVE_FILE_NAME), "", { mode: 0o600 });
99
+ return { hostDir };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function bounded(promise, ms) {
106
+ let timer;
107
+ return Promise.race([
108
+ Promise.resolve(promise),
109
+ new Promise((_, reject) => {
110
+ // Ref'd on purpose: whoever awaits this bound must be woken by it, even when nothing else holds the loop.
111
+ timer = setTimeout(() => reject(new Error("live transcript timeout")), ms);
112
+ }),
113
+ ]).finally(() => clearTimeout(timer));
114
+ }
115
+
116
+ /**
117
+ * `escapeControls` with two exceptions, so a multi-line message or tool output stays multi-line for a reader: a line
118
+ * break (`\r\n` and a lone `\r` normalised to `\n`) and a tab stay real characters. Every other control and every
119
+ * bidi or invisible character is written out as `\u{XXXX}` as before. Readers split on the real newlines.
120
+ */
121
+ export function escapeText(text) {
122
+ return String(text ?? "")
123
+ .replace(/\r\n?/g, "\n")
124
+ .split("\n")
125
+ .map((line) => line.split("\t").map(escapeControls).join("\t"))
126
+ .join("\n");
127
+ }
128
+
129
+ /**
130
+ * The first `points` code points of `text`, cheaply: a string of at most `points` UTF-16 units has at most that many
131
+ * code points, and only a longer one is walked. Used to cut a redacted text down before it is escaped, so a megabyte
132
+ * line costs one redaction pass and never a megabyte of escaping or of queue.
133
+ */
134
+ export function headOf(text, points) {
135
+ if (text.length <= points) return text;
136
+ let i = 0;
137
+ let n = 0;
138
+ while (i < text.length && n < points) {
139
+ const c = text.charCodeAt(i);
140
+ i += c >= 0xd800 && c <= 0xdbff && i + 1 < text.length ? 2 : 1;
141
+ n += 1;
142
+ }
143
+ return text.slice(0, i);
144
+ }
145
+
146
+ /** Text truncated to `max` code points, without leaving half of an `escapeControls` escape at the cut. */
147
+ export function clip(text, max) {
148
+ const points = [...text];
149
+ if (points.length <= max) return { text, truncated: false };
150
+ return { text: points.slice(0, max).join("").replace(/\\u\{[0-9A-F]{0,6}$|\\u$|\\$/, ""), truncated: true };
151
+ }
152
+
153
+ /** The text parts of a pi content value (a string, or text and image parts), images as `[image]`. */
154
+ function contentText(content) {
155
+ if (typeof content === "string") return content;
156
+ if (!Array.isArray(content)) return "";
157
+ const parts = [];
158
+ for (const part of content) {
159
+ if (part?.type === "text" && typeof part.text === "string") parts.push(part.text);
160
+ else if (part?.type === "image") parts.push("[image]");
161
+ }
162
+ return parts.join("\n");
163
+ }
164
+
165
+ const toolName = (name) => (typeof name === "string" && LIVE_TOOL_RE.test(name) ? name : "?");
166
+
167
+ /**
168
+ * One pi session entry as the display entries it becomes: `[{ kind, ts, text?, tool?, error? }]`, before redaction.
169
+ * Shapes are the pinned pi's (`@earendil-works/pi-coding-agent` dist/core/session-manager.js, and pi-ai's message
170
+ * types): the `session` header, `message` entries with role `user`, `assistant` or `toolResult` (and pi's own
171
+ * `bashExecution`), `custom_message`, `compaction`, `branch_summary`, `model_change`, `thinking_level_change`,
172
+ * `context_edit`. `usage`, `custom`, `session_info` and `label` carry nothing a reader needs and are left out.
173
+ * Thinking is dropped: a model's private reasoning is not shown.
174
+ */
175
+ export function entriesOf(line, ts) {
176
+ switch (line?.type) {
177
+ case "session":
178
+ return [{ kind: "note", ts, text: "session started" }];
179
+ case "message": {
180
+ const message = line.message ?? {};
181
+ switch (message.role) {
182
+ case "user":
183
+ return [{ kind: "user", ts, text: contentText(message.content) }];
184
+ case "assistant": {
185
+ const out = [];
186
+ const content = Array.isArray(message.content) ? message.content : [];
187
+ const text = content
188
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
189
+ .map((p) => p.text)
190
+ .join("\n");
191
+ if (text.trim() !== "") out.push({ kind: "assistant", ts, text });
192
+ for (const part of content) {
193
+ if (part?.type !== "toolCall") continue;
194
+ let args;
195
+ try {
196
+ args = JSON.stringify(part.arguments ?? {}) ?? "";
197
+ } catch {
198
+ args = "";
199
+ }
200
+ out.push({ kind: "tool-call", ts, tool: toolName(part.name), text: args });
201
+ }
202
+ // The stop reason only when the turn did not end normally, as a token, never the provider's message.
203
+ if (message.stopReason === "error" || message.stopReason === "aborted") out.push({ kind: "note", ts, text: `the model's turn ended: ${message.stopReason}` });
204
+ return out;
205
+ }
206
+ case "toolResult":
207
+ return [{ kind: "tool-result", ts, tool: toolName(message.toolName), text: contentText(message.content), ...(message.isError === true ? { error: true } : {}) }];
208
+ case "bashExecution":
209
+ return [{ kind: "tool-result", ts, tool: "bash", text: `$ ${typeof message.command === "string" ? message.command : ""}\n${typeof message.output === "string" ? message.output : ""}` }];
210
+ default:
211
+ return [{ kind: "note", ts, text: `a ${toolName(message.role)} message` }];
212
+ }
213
+ }
214
+ case "custom_message":
215
+ return [{ kind: line.customType === "pi-dispatch-inbox" ? "steer" : "note", ts, text: `${toolName(line.customType)}: ${contentText(line.content)}` }];
216
+ case "compaction":
217
+ return [{ kind: "compaction", ts, text: typeof line.summary === "string" ? line.summary : "" }];
218
+ case "branch_summary":
219
+ return [{ kind: "compaction", ts, text: typeof line.summary === "string" ? line.summary : "" }];
220
+ case "model_change":
221
+ return [{ kind: "note", ts, text: `model: ${toolName(line.provider)}/${typeof line.modelId === "string" ? line.modelId : "?"}` }];
222
+ case "thinking_level_change":
223
+ return [{ kind: "note", ts, text: `thinking level: ${toolName(line.thinkingLevel)}` }];
224
+ case "context_edit":
225
+ return [{ kind: "note", ts, text: "an earlier message was edited out of the context" }];
226
+ default:
227
+ return [];
228
+ }
229
+ }
230
+
231
+ /** The run's outcome as the enum an `end` entry carries. */
232
+ export const outcomeOf = (outcome) => (LIVE_OUTCOMES.includes(outcome) ? outcome : "unknown");
233
+
234
+ /**
235
+ * `makeLiveTranscript({ file, jobId, redis, redact, now, pollMs, log, fs })` returns `{ start(), finish(outcome), poll() }`.
236
+ *
237
+ * - `start()` begins polling `file` every `pollMs`. Idempotent, and refused once `finish` was called.
238
+ * - `finish(outcome)` stops the timer, waits for a poll in flight, reads the file one last time, flushes what is
239
+ * pending, publishes `end` with `outcome` (the `LIVE_OUTCOMES` enum, anything else is `unknown`) and sets the
240
+ * ended TTL. Bounded by `FINISH_TIMEOUT_MS` whatever Valkey does; idempotent (a second call returns the first's
241
+ * promise). It must run BEFORE the job directory is removed, or the run's last lines are lost: `runContainer`
242
+ * calls it in the container's `close` handler, before it resolves, so before the processor's cleanup can run.
243
+ *
244
+ * READING. The file is opened per poll and read from the offset, which handles a file that does not exist yet (the
245
+ * first polls before pi writes its header), a file replaced under the same name (the runner's quarantine renames
246
+ * it), and a file rewritten in place (pi's `_rewriteFile`). A complete line is everything up to a newline; a partial
247
+ * last line is held until it completes. A size below the offset, a new inode, or a first line that changed means the
248
+ * file was rewritten: `reset` is published and the file is read again from 0. Residual: a rewrite to the same first
249
+ * line and a size above the offset is not seen; pi rewrites only on a format migration or a branch, neither of which
250
+ * a job does after its header.
251
+ *
252
+ * MEMORY. Each entry is redacted, escaped and cut to its final size when it is QUEUED, so the queue holds only what
253
+ * will be written (at most 8192 code points of text per entry) and is bounded in entries and in bytes. The whole
254
+ * text is redacted BEFORE any cut, so no cut can split a secret and leave its head standing. The final read is
255
+ * bounded too (`LIVE_FINAL_READ_MAX_BYTES`) and flushes after every chunk.
256
+ *
257
+ * ONE ATTEMPT, ONE WRITER. Once `finish` has settled (done, or its bound ran out), this publisher is CLOSED: no read is
258
+ * started, nothing is queued and nothing is written, ever, even when a read or a write it started before then
259
+ * completes later. That is what keeps a stale attempt out of the next attempt's stream, which the next attempt's first
260
+ * write emptied. Residual: a transaction already handed to the client before `finish` settled can still land after
261
+ * it (the client resends one in flight across a reconnect). A write is never handed to a client that is not `ready`.
262
+ *
263
+ * ON A RETRY the job id, and so the key, is the same. The first write of an attempt deletes what an earlier attempt
264
+ * left, in the same transaction, so a stream holds one attempt and its `end` is that attempt's.
265
+ *
266
+ * RUN PROGRESS (issue #644). `onParsed(parsed, ts, { prior, replay, end })` is handed every parsed line, before and apart
267
+ * from the stream mapping, and never once the publisher is closed; a throw from it is swallowed. `prior` marks a line
268
+ * that ended at or before the BASELINE, the file's size when the first read began (`lstat`; a missing file or anything
269
+ * but a regular file is 0): on a resume those are the earlier runs' lines. After a reset there is no prior any more, and
270
+ * `replay` marks a line that ended at or before the rewritten file's size at the reset (run-progress.mjs states what
271
+ * the reducer does with it). `onReset()` is called at each reset. `stream: false` is the mode for progress with
272
+ * transcripts off: the file is tailed and every line handed to `onParsed`, and nothing is queued or written to the
273
+ * stream, so no `run:live:` key is ever touched.
274
+ *
275
+ * THE STDIO SOURCE (issue #686). `source: "stdio"` drives the same publisher with PUSHED lines instead of a file tail,
276
+ * for a venue that copies the session file into its sandbox (`openshell`), whose runner mirrors the file's lines over
277
+ * stderr as signed records (stderr-transcript.mjs verifies them). No file is read, ever: `file` is ignored. The returned
278
+ * object gains `setBaseline(size)`, `push(lineBytes, end)` (one complete line, without its newline, and the file
279
+ * position just past its newline, as the runner read it), `skip(count)` (lines the runner did not send, shown as one
280
+ * `skipped` entry, as an oversize line is here) and `reset(size)` (the runner saw its file rewritten, `size` its size
281
+ * then: the `reset` entry and `onReset`, exactly as a rewrite seen here). Every pushed line takes the per-line path a
282
+ * tailed line takes (`publishLine`: the parse, `onParsed`, the mapping, the redaction, the bounds), with the runner's
283
+ * own `end`, so positions stay exact across a gap. THE BASELINE is the runner's: the session file's size when its
284
+ * mirror started, before pi loaded, sent as the first signed record (`setBaseline`). A resumed session's staged
285
+ * transcript lies at or before it, so those lines are `prior`, as the file tail's `lstat` at spawn makes them on a
286
+ * binding venue. Taken from the runner rather than from the host's staged copy before the upload because it is the
287
+ * file the lines are read from (what the upload wrote, after anything the runner did to it before pi loaded), and it
288
+ * rides the same signed channel, in order, before any line. `onBaseline` is called once the lines up to it were pushed
289
+ * (at once for an empty one), or, failing open, at the first line when no baseline record arrived (it was broken: no
290
+ * line is then `prior`), at a reset, or at `finish`. After a reset, `replay` marks the lines ending at or before the
291
+ * reset's size, the rule a rewritten file gets. A line whose `end` is not past the last one's, with no reset between,
292
+ * implies a reset whose record was lost: it is applied, with the last end as its size. The timer only flushes;
293
+ * `finish` flushes what was pushed and publishes `end`. A line pushed once `finish` began is not taken.
294
+ */
295
+ export function makeLiveTranscript({ file, jobId, redis, redact = (t) => String(t ?? ""), now = () => Date.now(), pollMs = 500, log = () => {}, fs = { open: fsOpen, lstat: fsLstat, lstatSync: fsLstatSync }, timeoutMs = OP_TIMEOUT_MS, finishTimeoutMs = FINISH_TIMEOUT_MS, lineMaxBytes = LIVE_LINE_MAX_BYTES, pendingMax = PENDING_MAX, pendingMaxBytes = PENDING_MAX_BYTES, finalReadMaxBytes = LIVE_FINAL_READ_MAX_BYTES, readChunkBytes = READ_CHUNK_BYTES, stream = true, onParsed = null, onReset = null, onBaseline = null, source = "file" } = {}) {
296
+ const stdio = source === "stdio";
297
+ const key = liveKeyFor(jobId);
298
+ const id = sanitizeJobId(jobId);
299
+ let offset = 0;
300
+ let ino = null;
301
+ let refused = false;
302
+ let firstLine = null; // the first line's bytes (up to the probe), once a full one was read
303
+ let held = Buffer.alloc(0);
304
+ let skipping = false; // inside an oversize line: drop bytes until its newline
305
+ let timer = null;
306
+ let inFlight = null;
307
+ let finishing = null;
308
+ let closed = false; // finish has settled: nothing is read, queued or written from here on
309
+ let started = false;
310
+ let firstWriteDone = false; // the attempt's DEL and the live TTL have landed
311
+ const pending = []; // [{ fields, size }], each entry already in its final form
312
+ let pendingBytes = 0;
313
+ const counts = { published: 0, dropped: 0, skipped: 0, notes: 0, resets: 0, errors: 0, refused: 0 };
314
+ let warned = false;
315
+ let baseline = 0; // the file's size when the first read began: lines ending at or before it are `prior`
316
+ let baselineTaken = null;
317
+ let replayUntil = null; // set at a reset: the rewritten file's size then, lines ending at or before it are `replay`
318
+ let baselineReported = false;
319
+
320
+ /**
321
+ * Issue #646: `onBaseline()` is called ONCE, when this attempt has read the file up to its baseline (every `prior`
322
+ * line handed over), so a consumer can act on the whole of the staged transcript before anything new: the steer ack
323
+ * learns which messages a restored transcript already holds before the relay writes a file. Also called, FAILING
324
+ * OPEN, when the file was refused or a read failed: a consumer that waits for it must never wait forever, and the
325
+ * cost of an early call is a message delivered twice, where a call that never came would deliver none.
326
+ */
327
+ const reportBaseline = () => {
328
+ if (baselineReported || typeof onBaseline !== "function" || closed) return;
329
+ baselineReported = true;
330
+ try {
331
+ onBaseline();
332
+ } catch {}
333
+ };
334
+
335
+ /**
336
+ * The baseline, once: the file's size before anything of this attempt was read. Never rejects. Only `onParsed` reads
337
+ * it, so without one no `lstat` is added and the stream reads the file exactly as it did before issue #644.
338
+ *
339
+ * SYNCHRONOUS when it can be. `start` runs at spawn, before pi can write a line; an awaited `lstat` resolves a tick
340
+ * later, and a line pi (or a fast test writer) appended in between would be taken as an earlier run's and counted
341
+ * as `prior` instead of this attempt's. The async path is kept for an injected `fs` without `lstatSync`.
342
+ */
343
+ const takeBaseline = () => {
344
+ // Issue #686: a pushed source has no file and no earlier content; its baseline is 0 by construction.
345
+ if (stdio) return undefined;
346
+ if (typeof onParsed !== "function" && typeof onBaseline !== "function") return undefined;
347
+ if (baselineTaken === null && typeof fs.lstatSync === "function") {
348
+ try {
349
+ const info = fs.lstatSync(file);
350
+ baseline = info.isFile() ? info.size : 0;
351
+ } catch {
352
+ baseline = 0;
353
+ }
354
+ baselineTaken = Promise.resolve();
355
+ }
356
+ baselineTaken ??= (async () => {
357
+ try {
358
+ const info = await fs.lstat(file);
359
+ baseline = info.isFile() ? info.size : 0;
360
+ } catch {
361
+ baseline = 0;
362
+ }
363
+ })();
364
+ return baselineTaken;
365
+ };
366
+
367
+ /**
368
+ * The flat field list of one entry, every value a string: the text redacted (the WHOLE text, before any cut), then
369
+ * cut to a head that cannot shrink below the limit when escaped (`\r\n` becoming `\n` is the only shrink, by half),
370
+ * then escaped, then cut by code points. The tool name redacted too, `?` when that changed it. The timestamp the pi
371
+ * entry's own when it is a sane integer (not negative, not more than a day ahead), else the publish time. Null when
372
+ * the redactor threw: that entry is not published at all, never its raw text.
373
+ */
374
+ const fieldsOf = (entry) => {
375
+ const at = now();
376
+ const ts = Number.isFinite(entry.ts) && entry.ts >= 0 && entry.ts <= at + TS_FUTURE_MS ? Math.trunc(entry.ts) : at;
377
+ const fields = ["kind", entry.kind, "ts", String(ts)];
378
+ try {
379
+ let tool;
380
+ if (entry.tool !== undefined) {
381
+ tool = toolName(entry.tool);
382
+ if (tool !== "?" && redact(tool) !== tool) tool = "?";
383
+ }
384
+ if (typeof entry.text === "string") {
385
+ const max = entry.kind === "assistant" ? LIVE_ASSISTANT_TEXT_MAX : LIVE_TEXT_MAX;
386
+ const { text, truncated } = clip(escapeText(headOf(String(redact(entry.text)), 2 * max + 2)), max);
387
+ fields.push("text", text);
388
+ if (tool !== undefined) fields.push("tool", tool);
389
+ if (entry.error === true) fields.push("error", "1");
390
+ if (truncated) fields.push("truncated", "1");
391
+ } else if (tool !== undefined) {
392
+ fields.push("tool", tool);
393
+ }
394
+ } catch {
395
+ return null;
396
+ }
397
+ if (entry.kind === "end") fields.push("outcome", entry.outcome);
398
+ return fields;
399
+ };
400
+
401
+ const enqueue = (entry) => {
402
+ if (closed || !stream) return;
403
+ const fields = fieldsOf(entry);
404
+ if (fields === null) {
405
+ counts.errors += 1;
406
+ return;
407
+ }
408
+ const size = fields.reduce((n, f) => n + f.length * 2, 0);
409
+ pending.push({ fields, size });
410
+ pendingBytes += size;
411
+ // The oldest go first; the newest entry always stays, whatever its size.
412
+ while (pending.length > 1 && (pending.length > pendingMax || pendingBytes > pendingMaxBytes)) {
413
+ pendingBytes -= pending.shift().size;
414
+ counts.dropped += 1;
415
+ }
416
+ };
417
+
418
+ /** One complete line, `end` the file position just past it (where its newline was); `complete` false for the final unterminated tail. */
419
+ const publishLine = (bytes, end, complete = true) => {
420
+ if (closed) return;
421
+ const ts = now();
422
+ let parsed;
423
+ try {
424
+ parsed = JSON.parse(bytes.toString("utf8"));
425
+ } catch {
426
+ counts.notes += 1;
427
+ enqueue({ kind: "note", ts, text: UNPARSEABLE_NOTE });
428
+ return;
429
+ }
430
+ const at = typeof parsed?.timestamp === "string" ? Date.parse(parsed.timestamp) : Number.isFinite(parsed?.timestamp) ? parsed.timestamp : NaN;
431
+ const entryTs = Number.isFinite(at) ? at : ts;
432
+ if (typeof onParsed === "function") {
433
+ // The same bound `fieldsOf` puts on the stream's `ts`: the line is the agent's to write, and the progress
434
+ // reducer keeps this as a step's `since` (the key's `running`, and `longestInterruptedMs` in the record).
435
+ const parsedTs = entryTs >= 0 && entryTs <= ts + TS_FUTURE_MS ? Math.trunc(entryTs) : ts;
436
+ try {
437
+ // `end` (issue #645): the file position just past this line's newline, so a checkpoint consumer knows a
438
+ // complete-line boundary. Absent for the final read's unterminated tail, which ends at no newline.
439
+ const endOf = complete ? { end } : {};
440
+ onParsed(parsed, parsedTs, replayUntil === null ? { prior: end <= baseline, ...endOf } : { prior: false, replay: end <= replayUntil, ...endOf });
441
+ } catch {}
442
+ }
443
+ if (!stream) return;
444
+ for (const entry of entriesOf(parsed, entryTs)) enqueue(entry);
445
+ };
446
+
447
+ /** Split `chunk` (appended bytes, the first at file position `base`) into lines, holding a partial tail and skipping oversize lines. */
448
+ const takeBytes = (chunk, base) => {
449
+ let start = 0;
450
+ for (;;) {
451
+ const nl = chunk.indexOf(0x0a, start);
452
+ if (nl === -1) break;
453
+ const piece = chunk.subarray(start, nl);
454
+ start = nl + 1;
455
+ if (skipping) {
456
+ skipping = false;
457
+ held = Buffer.alloc(0);
458
+ continue;
459
+ }
460
+ const line = held.length > 0 ? Buffer.concat([held, piece]) : piece;
461
+ held = Buffer.alloc(0);
462
+ if (firstLine === null) firstLine = Buffer.from(line.subarray(0, FIRST_LINE_PROBE_BYTES));
463
+ if (line.length > lineMaxBytes) {
464
+ counts.skipped += 1;
465
+ enqueue({ kind: "skipped", ts: now() });
466
+ continue;
467
+ }
468
+ if (line.length === 0) continue;
469
+ publishLine(line, base + start);
470
+ }
471
+ const tail = chunk.subarray(start);
472
+ if (skipping || tail.length === 0) return;
473
+ if (held.length + tail.length > lineMaxBytes) {
474
+ // An oversize line still being written: said once, now, and the rest of it is dropped as it arrives.
475
+ if (firstLine === null) firstLine = Buffer.from(Buffer.concat([held, tail]).subarray(0, FIRST_LINE_PROBE_BYTES));
476
+ held = Buffer.alloc(0);
477
+ skipping = true;
478
+ counts.skipped += 1;
479
+ enqueue({ kind: "skipped", ts: now() });
480
+ return;
481
+ }
482
+ held = Buffer.concat([held, tail]);
483
+ };
484
+
485
+ /** The file was rewritten (`size` is its size as seen now): read it again from 0. */
486
+ const restart = (size) => {
487
+ offset = 0;
488
+ firstLine = null;
489
+ held = Buffer.alloc(0);
490
+ skipping = false;
491
+ counts.resets += 1;
492
+ replayUntil = Number.isSafeInteger(size) && size >= 0 ? size : 0;
493
+ enqueue({ kind: "reset", ts: now() });
494
+ if (typeof onReset === "function") {
495
+ try {
496
+ onReset();
497
+ } catch {}
498
+ }
499
+ };
500
+
501
+ /**
502
+ * The file is the AGENT's: `/session` is mounted read-write into the job, so the agent can replace `current.jsonl`
503
+ * with a symlink to any host path, or with a FIFO. The worker reads as its own account, so following that link would
504
+ * publish whatever host file it named (another job's stored transcript is exactly pi-shaped). So: `lstat` first and
505
+ * refuse anything but a regular file, then open and judge the OPEN DESCRIPTOR: it must be a regular file with the
506
+ * inode `lstat` saw. That inode check is the rule that closes a swap between the two calls, a link included (a link
507
+ * opened through resolves to its target's inode, which is not the one `lstat` saw). O_NOFOLLOW (`ELOOP` on a link)
508
+ * and the descriptor's `isFile` are defence in depth behind it: no swap is known that only they stop, since a link
509
+ * whose target has the inode `lstat` saw (a hard link to this very file) reads this very file. O_NONBLOCK is not
510
+ * optional: a FIFO swapped in would otherwise hang the open itself, before any check could run. A refused file is
511
+ * said once, as a fixed note, and never read again for this job.
512
+ */
513
+ const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
514
+ const refuse = () => {
515
+ refused = true;
516
+ counts.refused += 1;
517
+ enqueue({ kind: "note", text: REFUSED_NOTE, ts: now() });
518
+ };
519
+
520
+ /**
521
+ * One read of whatever was appended since the last. `final` reads to the end (at most `finalReadMaxBytes`, flushing
522
+ * after every chunk) and takes a partial tail as a line once the end was reached. No read starts after `finish`
523
+ * settled (`readAt`), and what a read still in flight then returns is never queued (`enqueue`).
524
+ */
525
+ const readOnce = async (final = false) => {
526
+ // Issue #686: a pushed source reads nothing; its lines arrive through `push`.
527
+ if (stdio) return undefined;
528
+ if (refused) return reportBaseline();
529
+ await takeBaseline();
530
+ let handle = null;
531
+ let failed = false;
532
+ try {
533
+ let info;
534
+ try {
535
+ info = await fs.lstat(file);
536
+ } catch {
537
+ return; // not there yet (or gone with the job directory): nothing to read
538
+ }
539
+ if (!info.isFile()) return refuse();
540
+ if ((ino !== null && info.ino !== ino && offset > 0) || info.size < offset) restart(info.size);
541
+ ino = info.ino;
542
+ try {
543
+ handle = await fs.open(file, OPEN_FLAGS);
544
+ } catch (err) {
545
+ if (err?.code === "ELOOP") return refuse();
546
+ throw err;
547
+ }
548
+ const opened = await handle.stat();
549
+ // Every read goes through here: none starts once `finish` settled, whatever this read was doing then.
550
+ const readAt = async (buf, length, position) => {
551
+ if (closed) throw new Error("live transcript closed");
552
+ return handle.read(buf, 0, length, position);
553
+ };
554
+ if (!opened.isFile() || opened.ino !== info.ino) return refuse();
555
+ if (firstLine !== null && offset > 0) {
556
+ const probe = Buffer.alloc(firstLine.length);
557
+ const { bytesRead } = await readAt(probe, probe.length, 0);
558
+ if (bytesRead < firstLine.length || !probe.equals(firstLine)) restart(info.size);
559
+ }
560
+ let budget = final ? finalReadMaxBytes : readChunkBytes;
561
+ let atEnd = false;
562
+ for (;;) {
563
+ const size = (await handle.stat()).size;
564
+ if (size <= offset) {
565
+ atEnd = true;
566
+ break;
567
+ }
568
+ if (budget <= 0) {
569
+ // The final read's budget is spent and the file goes on: said once, and the rest is not read.
570
+ counts.skipped += 1;
571
+ enqueue({ kind: "skipped", ts: now() });
572
+ break;
573
+ }
574
+ const want = Math.min(size - offset, readChunkBytes, budget);
575
+ const buf = Buffer.alloc(want);
576
+ const { bytesRead } = await readAt(buf, want, offset);
577
+ if (bytesRead <= 0) break;
578
+ const base = offset;
579
+ offset += bytesRead;
580
+ budget -= bytesRead;
581
+ takeBytes(buf.subarray(0, bytesRead), base);
582
+ if (!final) break;
583
+ await flush();
584
+ }
585
+ if (final && atEnd && held.length > 0 && !skipping) {
586
+ const line = held;
587
+ held = Buffer.alloc(0);
588
+ publishLine(line, offset, false);
589
+ }
590
+ } catch {
591
+ counts.errors += 1;
592
+ failed = true;
593
+ } finally {
594
+ await handle?.close().catch(() => {});
595
+ if (refused || failed || offset >= baseline) reportBaseline();
596
+ }
597
+ };
598
+
599
+ const fail = () => {
600
+ counts.errors += 1;
601
+ if (!warned) {
602
+ warned = true;
603
+ log("live_transcript_write_failed", { jobId: id });
604
+ }
605
+ };
606
+
607
+ /**
608
+ * Write every pending entry, then `extra` (raw entries, the `end`), in one bounded transaction; on any failure,
609
+ * including a reply that carries an error for any command in it, the batch is dropped and counted. Nothing once
610
+ * closed, and nothing handed to a client that says it is not `ready` (it would queue or resend it later).
611
+ */
612
+ const flush = async (extra = []) => {
613
+ // No client: nothing to write to, so the queue keeps its bound and nothing else happens (a test seam; the opener
614
+ // builds no publisher without a client). With the stream off nothing is ever written to it, the `end` included.
615
+ if (closed || !redis || !stream) return;
616
+ // A client still connecting (or reconnecting) is not a failure: the queue keeps its entries, within its bound, for
617
+ // the next flush. Only a flush that carries the end (`extra`) proceeds, since nothing comes after it to retry.
618
+ if (typeof redis.status === "string" && redis.status !== "ready" && extra.length === 0) return;
619
+ const batch = pending.splice(0, pending.length).map((p) => p.fields);
620
+ pendingBytes = 0;
621
+ for (const entry of extra) {
622
+ const fields = fieldsOf(entry);
623
+ if (fields !== null) batch.push(fields);
624
+ }
625
+ if (batch.length === 0) return;
626
+ const ended = extra.some((e) => e.kind === "end");
627
+ try {
628
+ if (typeof redis.status === "string" && redis.status !== "ready") throw new Error("live transcript client not ready");
629
+ const tx = redis.multi();
630
+ if (!firstWriteDone) tx.del(key);
631
+ for (const fields of batch) tx.xadd(key, "MAXLEN", "~", String(LIVE_MAX_ENTRIES), "*", ...fields);
632
+ if (!firstWriteDone || ended) tx.pexpire(key, ended ? LIVE_TTL_ENDED_MS : LIVE_TTL_LIVE_MS);
633
+ const result = await bounded(tx.exec(), timeoutMs);
634
+ if (!Array.isArray(result) || result.some((r) => Array.isArray(r) && r[0])) throw new Error("live transcript write refused");
635
+ firstWriteDone = true;
636
+ counts.published += batch.length;
637
+ } catch {
638
+ counts.dropped += batch.length;
639
+ fail();
640
+ }
641
+ };
642
+
643
+ const tick = () => {
644
+ if (inFlight || finishing) return inFlight;
645
+ inFlight = (async () => {
646
+ try {
647
+ await readOnce(false);
648
+ await flush();
649
+ } catch {
650
+ counts.errors += 1;
651
+ }
652
+ })().finally(() => {
653
+ inFlight = null;
654
+ });
655
+ return inFlight;
656
+ };
657
+
658
+ // Issue #686: the stdio source's own state: the last line's end (a fallback position for a line pushed without one),
659
+ // and whether the runner's baseline record arrived.
660
+ let position = 0;
661
+ let baselineKnown = false;
662
+ const stdioMethods = stdio
663
+ ? {
664
+ /** The runner's baseline: the file's size before pi loaded. Once, before any line; later calls are ignored. */
665
+ setBaseline(size) {
666
+ if (closed || finishing || baselineKnown) return;
667
+ baselineKnown = true;
668
+ baseline = Number.isSafeInteger(size) && size >= 0 ? size : 0;
669
+ if (baseline === 0) reportBaseline();
670
+ },
671
+ /** One complete line, without its newline, and its end in the file. Never throws; nothing once `finish` began. */
672
+ push(lineBytes, end) {
673
+ if (closed || finishing) return;
674
+ try {
675
+ const line = Buffer.isBuffer(lineBytes) ? lineBytes : Buffer.from(String(lineBytes ?? ""), "utf8");
676
+ // A line that ends at or before the last one, with no reset between, means the file was rewritten and
677
+ // the reset record was lost (broken in transit): the reset it implies, with the last end as its size.
678
+ if (Number.isSafeInteger(end) && end > 0 && position > 0 && end <= position) restart(position);
679
+ position = Number.isSafeInteger(end) && end > 0 ? end : position + line.length + 1;
680
+ // No baseline record came first (it was broken): failing open, nothing is `prior`.
681
+ baselineKnown = true;
682
+ if (line.length > lineMaxBytes) {
683
+ counts.skipped += 1;
684
+ enqueue({ kind: "skipped", ts: now() });
685
+ } else if (line.length > 0) {
686
+ publishLine(line, position);
687
+ }
688
+ if (position >= baseline) reportBaseline();
689
+ } catch {
690
+ counts.errors += 1;
691
+ }
692
+ },
693
+ /** `count` lines the source did not deliver: one `skipped` entry, each counted. */
694
+ skip(count = 1) {
695
+ if (closed || finishing) return;
696
+ const n = Number.isSafeInteger(count) && count > 0 ? count : 1;
697
+ counts.skipped += n;
698
+ enqueue({ kind: "skipped", ts: now() });
699
+ },
700
+ /** The source's file was rewritten, `size` its size then: what `restart` does for a tailed file. */
701
+ reset(size) {
702
+ if (closed || finishing) return;
703
+ restart(Number.isSafeInteger(size) && size >= 0 ? size : position);
704
+ position = 0;
705
+ reportBaseline();
706
+ },
707
+ }
708
+ : {};
709
+
710
+ return {
711
+ /** One poll now (read what was appended, then flush), the same the timer runs. For tests; never throws. */
712
+ poll: () => tick() ?? Promise.resolve(),
713
+ /** What is held in memory right now, and the counts. For tests. */
714
+ stats: () => ({ pending: pending.length, pendingBytes, held: held.length, closed, ...counts }),
715
+ ...stdioMethods,
716
+ start() {
717
+ if (started || finishing) return;
718
+ started = true;
719
+ void takeBaseline();
720
+ timer = setInterval(tick, pollMs);
721
+ timer.unref?.();
722
+ void tick();
723
+ },
724
+ finish(outcome) {
725
+ if (finishing) return finishing;
726
+ if (timer) clearInterval(timer);
727
+ timer = null;
728
+ const work = (async () => {
729
+ try {
730
+ await inFlight;
731
+ } catch {}
732
+ await readOnce(true);
733
+ // Issue #686: a pushed source that never reached its baseline still says so, failing open.
734
+ if (stdio) reportBaseline();
735
+ await flush([{ kind: "end", ts: now(), outcome: outcomeOf(outcome) }]);
736
+ })();
737
+ finishing = bounded(work, finishTimeoutMs)
738
+ .catch(() => fail())
739
+ .then(() => {
740
+ // Closed whichever way the bound settled: a read or a write still running in the background is
741
+ // never followed by another, and what it read is never queued.
742
+ closed = true;
743
+ pending.length = 0;
744
+ pendingBytes = 0;
745
+ log("live_transcript_ended", { jobId: id, ...counts });
746
+ });
747
+ return finishing;
748
+ },
749
+ };
750
+ }
751
+
752
+ /**
753
+ * The opener `runContainer` is handed (start.mjs builds it when `PI_LIVE_TRANSCRIPTS` or `PI_RUN_PROGRESS` is on and a
754
+ * Valkey client exists, and since issue #645 whenever a run checkpointer exists): `({ file, jobId, redact, job,
755
+ * checkpoint }) => { start, finish, progressSummary, checkpoint } | null`. A seam, so a test wires a fake publisher or none.
756
+ *
757
+ * - `transcripts` writes the stream (#643); `progress` builds the run progress sink (#644, run-progress.mjs) and hands
758
+ * it every parsed line. With transcripts off and progress on, the file is tailed and nothing reaches the stream.
759
+ * - `makeCommenter({ job, jobId, commentId, onId, finalTimeoutMs })` builds the forge status commenter
760
+ * (status-comment.mjs), asked only for a job whose trigger set `run.statusComment` and only with progress on; it
761
+ * may return null (start.mjs does for a job with no forge to post to). `job.progress === true` makes the sink read
762
+ * `report_progress` phases.
763
+ * - `checkpoint` (issue #645) is the run checkpointer's `open` (run-checkpoint.mjs). Called per attempt, only when
764
+ * runContainer hands open args (`checkpoint`: a `run.recover` job the processor armed), with `sessionFile` set to
765
+ * the tailed file. Its handle is the THIRD consumer of every parsed line, beside the progress sink, and is returned
766
+ * as `checkpoint` for runContainer to close and hand back. It turns the tailing on by itself: a recover job with
767
+ * both switches off still has its file tailed (`stream: false`, no progress sink), because the live checkpoints
768
+ * are driven by its lines. A factory that throws costs the checkpoints, never the stream or the run.
769
+ * - `finish(outcome)` finishes the stream first (its own bound, `FINISH_TIMEOUT_MS`, which also closes the publisher,
770
+ * so no line is handed to the sink after it), then the sink (a wait for a comment post in flight and the final
771
+ * write, its own bound of 5 s). Worst case at a run's end: 3 s plus 5 s, and the 5 s only when Valkey or the forge
772
+ * hangs. The comment's final edit is not in it: start.mjs makes that one from the run record. The checkpoint handle
773
+ * is NOT closed here: runContainer closes it, so it can stop new live checkpoints before this final read.
774
+ * - `progressSummary()` is the record's `progress` (run-history.mjs `recordedProgress`), or null with progress off.
775
+ * - `steer` (issue #646): the factory flag says this deployment can arm steering, so the opener is built even with
776
+ * every other reader off; per job, `steer` is the ack consumer (steer-ack.mjs `makeSteerAck`) runContainer hands for
777
+ * an ARMED attempt. It is the third consumer of every parsed line and hears `onBaseline`, and like the checkpoint
778
+ * it turns the tailing on by itself (`stream: false`): the acks are what count a delivery, so a steer job with
779
+ * transcripts, progress and checkpoints all off still has its file read. An unarmed job hands none, and its opener
780
+ * is exactly what it was.
781
+ * - `source` (issue #686): `"stdio"` opens the publisher on PUSHED lines (`makeLiveTranscript`'s stdio source), for a
782
+ * venue whose runner mirrors the session file over stderr; the handle then also carries `setBaseline`, `push`,
783
+ * `skip` and `reset`,
784
+ * which runContainer's stderr demux calls. `file` is ignored, and no checkpoint is opened (the checkpointer copies a
785
+ * file on the host, and there is none until the download). Every other consumer reads the pushed lines as it reads
786
+ * tailed ones. Absent, `"file"`, and the opener is exactly what it was.
787
+ */
788
+ export function makeLiveTranscriptOpener({ redis, log = () => {}, now = () => Date.now(), pollMs = 500, transcripts = true, progress = false, makeCommenter = null, progressWriter = {}, checkpoint = null, steer = false } = {}) {
789
+ const streamOn = Boolean(redis) && transcripts;
790
+ const progressOn = Boolean(redis) && progress;
791
+ if (!streamOn && !progressOn && typeof checkpoint !== "function" && steer !== true) return null;
792
+ return ({ file, jobId, redact, job = null, checkpoint: checkpointArgs = null, steer: steerAck = null, source = "file" }) => {
793
+ const stdio = source === "stdio";
794
+ let handle = null;
795
+ if (!stdio && typeof checkpoint === "function" && checkpointArgs && typeof checkpointArgs === "object") {
796
+ try {
797
+ handle = checkpoint({ ...checkpointArgs, sessionFile: file }) ?? null;
798
+ } catch {
799
+ handle = null;
800
+ }
801
+ }
802
+ // Nothing would read the file: no stream, no progress, no checkpoints, no steer acks. runContainer runs the job as before.
803
+ if (!streamOn && !progressOn && handle === null && !steerAck) return null;
804
+ // A trigger that asked for a status comment on a deployment with progress off gets none: said once per job, never
805
+ // a silent no-op (the comment is driven by progress, so there is nothing to post).
806
+ if (!progressOn && job?.statusComment === true) log("status_comment_skipped", { jobId: sanitizeJobId(jobId), reason: "progress-off" });
807
+ const sink = progressOn
808
+ ? makeProgressSink({
809
+ redis,
810
+ jobId,
811
+ phasesEnabled: job?.progress === true,
812
+ redact,
813
+ now,
814
+ log,
815
+ writer: progressWriter,
816
+ makeCommenter: typeof makeCommenter === "function" && job?.statusComment === true ? (opts) => makeCommenter({ job, ...opts }) : null,
817
+ })
818
+ : null;
819
+ // Each consumer guarded on its own, so a throw in one never starves another of the line. The steer ack (issue #646)
820
+ // is the third, beside the progress sink and the checkpoint handle.
821
+ const parsedConsumers = [];
822
+ if (sink) parsedConsumers.push({ consume: (parsed, ts, flags) => sink.consume(parsed, ts, flags), reset: () => sink.reset() });
823
+ if (handle) parsedConsumers.push({ consume: (parsed, ts, flags) => handle.consume(parsed, ts, flags?.end, flags), reset: () => handle.reset() });
824
+ if (steerAck) parsedConsumers.push({ consume: (parsed, ts, flags) => steerAck.consume(parsed, ts, flags), reset: () => steerAck.reset?.() });
825
+ const consumers =
826
+ parsedConsumers.length > 0
827
+ ? {
828
+ onParsed: (parsed, ts, flags) => {
829
+ for (const c of parsedConsumers) {
830
+ try {
831
+ c.consume(parsed, ts, flags);
832
+ } catch {}
833
+ }
834
+ },
835
+ onReset: () => {
836
+ for (const c of parsedConsumers) {
837
+ try {
838
+ c.reset();
839
+ } catch {}
840
+ }
841
+ },
842
+ }
843
+ : {};
844
+ if (steerAck && typeof steerAck.onBaseline === "function") consumers.onBaseline = () => steerAck.onBaseline();
845
+ const live = makeLiveTranscript({ file: stdio ? null : file, jobId, redis, redact, now, pollMs, log, stream: streamOn, ...consumers, ...(stdio ? { source: "stdio" } : {}) });
846
+ let finishing = null;
847
+ return {
848
+ ...(stdio ? { setBaseline: live.setBaseline, push: live.push, skip: live.skip, reset: live.reset } : {}),
849
+ poll: live.poll,
850
+ stats: () => ({ ...live.stats(), ...(sink ? { progress: sink.stats() } : {}), ...(handle ? { checkpoint: handle.stats() } : {}), ...(typeof steerAck?.stats === "function" ? { steer: steerAck.stats() } : {}) }),
851
+ start() {
852
+ live.start();
853
+ try {
854
+ sink?.start();
855
+ } catch {}
856
+ },
857
+ finish(outcome) {
858
+ finishing ??= (async () => {
859
+ try {
860
+ await live.finish(outcome);
861
+ } catch {}
862
+ try {
863
+ await sink?.finish(outcome);
864
+ } catch {}
865
+ })();
866
+ return finishing;
867
+ },
868
+ progressSummary: () => (sink ? sink.summary() : null),
869
+ checkpoint: handle,
870
+ };
871
+ };
872
+ }