@relayflows/sdk 2.0.30 → 2.0.32

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 (347) hide show
  1. package/dist/agent-artifacts.d.ts.map +1 -1
  2. package/dist/agent-artifacts.js +2 -9
  3. package/dist/agent-artifacts.js.map +1 -1
  4. package/dist/agent-cwd.d.ts +69 -0
  5. package/dist/agent-cwd.d.ts.map +1 -0
  6. package/dist/agent-cwd.js +140 -0
  7. package/dist/agent-cwd.js.map +1 -0
  8. package/dist/artifact-scan-policy.d.ts +35 -0
  9. package/dist/artifact-scan-policy.d.ts.map +1 -0
  10. package/dist/artifact-scan-policy.js +49 -0
  11. package/dist/artifact-scan-policy.js.map +1 -0
  12. package/dist/authored-completion-record.d.ts +35 -0
  13. package/dist/authored-completion-record.d.ts.map +1 -0
  14. package/dist/authored-completion-record.js +92 -0
  15. package/dist/authored-completion-record.js.map +1 -0
  16. package/dist/authored-completion.d.ts +99 -0
  17. package/dist/authored-completion.d.ts.map +1 -0
  18. package/dist/authored-completion.js +184 -0
  19. package/dist/authored-completion.js.map +1 -0
  20. package/dist/authored-flow-error.d.ts +13 -1
  21. package/dist/authored-flow-error.d.ts.map +1 -1
  22. package/dist/authored-flow-error.js +12 -0
  23. package/dist/authored-flow-error.js.map +1 -1
  24. package/dist/authored-flow-executor.d.ts +7 -30
  25. package/dist/authored-flow-executor.d.ts.map +1 -1
  26. package/dist/authored-flow-executor.js +54 -47
  27. package/dist/authored-flow-executor.js.map +1 -1
  28. package/dist/authored-human.d.ts.map +1 -1
  29. package/dist/authored-human.js +1 -3
  30. package/dist/authored-human.js.map +1 -1
  31. package/dist/authored-node-entry.js +4 -1
  32. package/dist/authored-node-entry.js.map +1 -1
  33. package/dist/authored-node-runner.d.ts +9 -1
  34. package/dist/authored-node-runner.d.ts.map +1 -1
  35. package/dist/authored-node-runner.js +92 -17
  36. package/dist/authored-node-runner.js.map +1 -1
  37. package/dist/authored-preflight.d.ts +4 -0
  38. package/dist/authored-preflight.d.ts.map +1 -0
  39. package/dist/authored-preflight.js +50 -0
  40. package/dist/authored-preflight.js.map +1 -0
  41. package/dist/authored-root.d.ts +5 -0
  42. package/dist/authored-root.d.ts.map +1 -1
  43. package/dist/authored-root.js +20 -2
  44. package/dist/authored-root.js.map +1 -1
  45. package/dist/authored-step-output.d.ts +35 -10
  46. package/dist/authored-step-output.d.ts.map +1 -1
  47. package/dist/authored-step-output.js +108 -40
  48. package/dist/authored-step-output.js.map +1 -1
  49. package/dist/authored-verdict.d.ts +28 -0
  50. package/dist/authored-verdict.d.ts.map +1 -0
  51. package/dist/authored-verdict.js +79 -0
  52. package/dist/authored-verdict.js.map +1 -0
  53. package/dist/authored-worker-step.d.ts +5 -2
  54. package/dist/authored-worker-step.d.ts.map +1 -1
  55. package/dist/authored-worker-step.js +51 -13
  56. package/dist/authored-worker-step.js.map +1 -1
  57. package/dist/cli/check-worker-surface.d.ts +27 -0
  58. package/dist/cli/check-worker-surface.d.ts.map +1 -0
  59. package/dist/cli/check-worker-surface.js +63 -0
  60. package/dist/cli/check-worker-surface.js.map +1 -0
  61. package/dist/cli/check.d.ts +28 -3
  62. package/dist/cli/check.d.ts.map +1 -1
  63. package/dist/cli/check.js +27 -140
  64. package/dist/cli/check.js.map +1 -1
  65. package/dist/cli/cli-probe.d.ts +20 -0
  66. package/dist/cli/cli-probe.d.ts.map +1 -0
  67. package/dist/cli/cli-probe.js +166 -0
  68. package/dist/cli/cli-probe.js.map +1 -0
  69. package/dist/cli/cloud-format.d.ts +9 -0
  70. package/dist/cli/cloud-format.d.ts.map +1 -0
  71. package/dist/cli/cloud-format.js +77 -0
  72. package/dist/cli/cloud-format.js.map +1 -0
  73. package/dist/cli/cloud-live.d.ts +39 -0
  74. package/dist/cli/cloud-live.d.ts.map +1 -0
  75. package/dist/cli/cloud-live.js +325 -0
  76. package/dist/cli/cloud-live.js.map +1 -0
  77. package/dist/cli/cloud-mirror-session.d.ts +97 -0
  78. package/dist/cli/cloud-mirror-session.d.ts.map +1 -0
  79. package/dist/cli/cloud-mirror-session.js +264 -0
  80. package/dist/cli/cloud-mirror-session.js.map +1 -0
  81. package/dist/cli/cloud-read.d.ts +3 -7
  82. package/dist/cli/cloud-read.d.ts.map +1 -1
  83. package/dist/cli/cloud-read.js +14 -266
  84. package/dist/cli/cloud-read.js.map +1 -1
  85. package/dist/cli/cloud-refusal.d.ts +39 -0
  86. package/dist/cli/cloud-refusal.d.ts.map +1 -0
  87. package/dist/cli/cloud-refusal.js +95 -0
  88. package/dist/cli/cloud-refusal.js.map +1 -0
  89. package/dist/cli/cloud-status-view.d.ts +7 -0
  90. package/dist/cli/cloud-status-view.d.ts.map +1 -0
  91. package/dist/cli/cloud-status-view.js +214 -0
  92. package/dist/cli/cloud-status-view.js.map +1 -0
  93. package/dist/cli/direct-run.d.ts.map +1 -1
  94. package/dist/cli/direct-run.js +16 -2
  95. package/dist/cli/direct-run.js.map +1 -1
  96. package/dist/cli/hn-monitor.d.ts +0 -13
  97. package/dist/cli/hn-monitor.d.ts.map +1 -1
  98. package/dist/cli/hn-monitor.js +2 -1
  99. package/dist/cli/hn-monitor.js.map +1 -1
  100. package/dist/cli/local-agent-remedy.d.ts +80 -0
  101. package/dist/cli/local-agent-remedy.d.ts.map +1 -0
  102. package/dist/cli/local-agent-remedy.js +116 -0
  103. package/dist/cli/local-agent-remedy.js.map +1 -0
  104. package/dist/cli/observer-session.d.ts +41 -0
  105. package/dist/cli/observer-session.d.ts.map +1 -0
  106. package/dist/cli/observer-session.js +78 -0
  107. package/dist/cli/observer-session.js.map +1 -0
  108. package/dist/cli/run.d.ts +30 -2
  109. package/dist/cli/run.d.ts.map +1 -1
  110. package/dist/cli/run.js +186 -24
  111. package/dist/cli/run.js.map +1 -1
  112. package/dist/cli/status.d.ts +9 -0
  113. package/dist/cli/status.d.ts.map +1 -1
  114. package/dist/cli/status.js +31 -2
  115. package/dist/cli/status.js.map +1 -1
  116. package/dist/cli/step-evidence.d.ts +90 -0
  117. package/dist/cli/step-evidence.d.ts.map +1 -0
  118. package/dist/cli/step-evidence.js +259 -0
  119. package/dist/cli/step-evidence.js.map +1 -0
  120. package/dist/cli/step-failure.d.ts +14 -4
  121. package/dist/cli/step-failure.d.ts.map +1 -1
  122. package/dist/cli/step-failure.js +41 -76
  123. package/dist/cli/step-failure.js.map +1 -1
  124. package/dist/cli-commands.d.ts +12 -0
  125. package/dist/cli-commands.d.ts.map +1 -1
  126. package/dist/cli-commands.js +3 -0
  127. package/dist/cli-commands.js.map +1 -1
  128. package/dist/cli.d.ts +17 -0
  129. package/dist/cli.d.ts.map +1 -1
  130. package/dist/cli.js +134 -57
  131. package/dist/cli.js.map +1 -1
  132. package/dist/cloud-deploy.d.ts +1 -1
  133. package/dist/cloud-deploy.d.ts.map +1 -1
  134. package/dist/cloud-deploy.js +24 -8
  135. package/dist/cloud-deploy.js.map +1 -1
  136. package/dist/cloud-mirror-ledger.d.ts +40 -0
  137. package/dist/cloud-mirror-ledger.d.ts.map +1 -0
  138. package/dist/cloud-mirror-ledger.js +142 -0
  139. package/dist/cloud-mirror-ledger.js.map +1 -0
  140. package/dist/cloud-mirror-step.d.ts +168 -0
  141. package/dist/cloud-mirror-step.d.ts.map +1 -0
  142. package/dist/cloud-mirror-step.js +504 -0
  143. package/dist/cloud-mirror-step.js.map +1 -0
  144. package/dist/cloud-mirror-transport.d.ts +94 -0
  145. package/dist/cloud-mirror-transport.d.ts.map +1 -0
  146. package/dist/cloud-mirror-transport.js +163 -0
  147. package/dist/cloud-mirror-transport.js.map +1 -0
  148. package/dist/cloud-mirror.d.ts +132 -0
  149. package/dist/cloud-mirror.d.ts.map +1 -0
  150. package/dist/cloud-mirror.js +556 -0
  151. package/dist/cloud-mirror.js.map +1 -0
  152. package/dist/cloud-read.d.ts +15 -0
  153. package/dist/cloud-read.d.ts.map +1 -1
  154. package/dist/cloud-read.js +20 -1
  155. package/dist/cloud-read.js.map +1 -1
  156. package/dist/cloud-run-record.d.ts +27 -0
  157. package/dist/cloud-run-record.d.ts.map +1 -0
  158. package/dist/cloud-run-record.js +56 -0
  159. package/dist/cloud-run-record.js.map +1 -0
  160. package/dist/cloud-run.d.ts +2 -9
  161. package/dist/cloud-run.d.ts.map +1 -1
  162. package/dist/cloud-run.js +3 -20
  163. package/dist/cloud-run.js.map +1 -1
  164. package/dist/communication/local.d.ts.map +1 -1
  165. package/dist/communication/local.js +2 -1
  166. package/dist/communication/local.js.map +1 -1
  167. package/dist/communication/worker.d.ts +1 -1
  168. package/dist/communication/worker.d.ts.map +1 -1
  169. package/dist/communication/worker.js +13 -5
  170. package/dist/communication/worker.js.map +1 -1
  171. package/dist/compile.d.ts.map +1 -1
  172. package/dist/compile.js +28 -6
  173. package/dist/compile.js.map +1 -1
  174. package/dist/direct-input.d.ts.map +1 -1
  175. package/dist/direct-input.js +21 -1
  176. package/dist/direct-input.js.map +1 -1
  177. package/dist/failure-kinds.d.ts +96 -6
  178. package/dist/failure-kinds.d.ts.map +1 -1
  179. package/dist/failure-kinds.js +29 -2
  180. package/dist/failure-kinds.js.map +1 -1
  181. package/dist/flow-requirements.d.ts.map +1 -1
  182. package/dist/flow-requirements.js +24 -9
  183. package/dist/flow-requirements.js.map +1 -1
  184. package/dist/gate-contract.d.ts +7 -0
  185. package/dist/gate-contract.d.ts.map +1 -1
  186. package/dist/gate-contract.js +6 -1
  187. package/dist/gate-contract.js.map +1 -1
  188. package/dist/helper-preflight.d.ts.map +1 -1
  189. package/dist/helper-preflight.js +10 -3
  190. package/dist/helper-preflight.js.map +1 -1
  191. package/dist/helper-reference.d.ts +25 -0
  192. package/dist/helper-reference.d.ts.map +1 -0
  193. package/dist/helper-reference.js +129 -0
  194. package/dist/helper-reference.js.map +1 -0
  195. package/dist/hosted-extension-runtime.js +1 -1
  196. package/dist/index.d.ts +1 -1
  197. package/dist/index.d.ts.map +1 -1
  198. package/dist/index.js.map +1 -1
  199. package/dist/journal-client.d.ts +3 -1
  200. package/dist/journal-client.d.ts.map +1 -1
  201. package/dist/journal-client.js +17 -2
  202. package/dist/journal-client.js.map +1 -1
  203. package/dist/journal-projection.d.ts +16 -0
  204. package/dist/journal-projection.d.ts.map +1 -0
  205. package/dist/journal-projection.js +108 -0
  206. package/dist/journal-projection.js.map +1 -0
  207. package/dist/llm-worker.js +1 -1
  208. package/dist/llm-worker.js.map +1 -1
  209. package/dist/local-agent.d.ts +1 -1
  210. package/dist/local-agent.d.ts.map +1 -1
  211. package/dist/local-agent.js +4 -3
  212. package/dist/local-agent.js.map +1 -1
  213. package/dist/named-gate-lowering.js +35 -5
  214. package/dist/named-gate-lowering.js.map +1 -1
  215. package/dist/named-gate-preflight.d.ts +27 -0
  216. package/dist/named-gate-preflight.d.ts.map +1 -0
  217. package/dist/named-gate-preflight.js +47 -0
  218. package/dist/named-gate-preflight.js.map +1 -0
  219. package/dist/named-gates.d.ts +1 -1
  220. package/dist/named-gates.d.ts.map +1 -1
  221. package/dist/named-gates.js +3 -0
  222. package/dist/named-gates.js.map +1 -1
  223. package/dist/observer-link.d.ts +7 -0
  224. package/dist/observer-link.d.ts.map +1 -1
  225. package/dist/observer-link.js +3 -1
  226. package/dist/observer-link.js.map +1 -1
  227. package/dist/package-version.d.ts +10 -0
  228. package/dist/package-version.d.ts.map +1 -0
  229. package/dist/package-version.js +22 -0
  230. package/dist/package-version.js.map +1 -0
  231. package/dist/preflight.d.ts +14 -0
  232. package/dist/preflight.d.ts.map +1 -1
  233. package/dist/preflight.js +25 -3
  234. package/dist/preflight.js.map +1 -1
  235. package/dist/protocol.d.ts +2 -0
  236. package/dist/protocol.d.ts.map +1 -1
  237. package/dist/redact.d.ts +42 -0
  238. package/dist/redact.d.ts.map +1 -1
  239. package/dist/redact.js +128 -0
  240. package/dist/redact.js.map +1 -1
  241. package/dist/relay-cli.d.ts.map +1 -1
  242. package/dist/relay-cli.js +1 -21
  243. package/dist/relay-cli.js.map +1 -1
  244. package/dist/run-projection.d.ts +83 -0
  245. package/dist/run-projection.d.ts.map +1 -0
  246. package/dist/run-projection.js +199 -0
  247. package/dist/run-projection.js.map +1 -0
  248. package/dist/shell-word.d.ts +19 -0
  249. package/dist/shell-word.d.ts.map +1 -0
  250. package/dist/shell-word.js +23 -0
  251. package/dist/shell-word.js.map +1 -0
  252. package/dist/spec.d.ts +52 -3
  253. package/dist/spec.d.ts.map +1 -1
  254. package/dist/spec.js.map +1 -1
  255. package/dist/step-fields.d.ts +1 -1
  256. package/dist/step-fields.js +1 -1
  257. package/dist/step-fields.js.map +1 -1
  258. package/dist/steps-green.d.ts +27 -0
  259. package/dist/steps-green.d.ts.map +1 -0
  260. package/dist/steps-green.js +164 -0
  261. package/dist/steps-green.js.map +1 -0
  262. package/dist/validate.d.ts.map +1 -1
  263. package/dist/validate.js +39 -8
  264. package/dist/validate.js.map +1 -1
  265. package/dist/worker-lease.d.ts +8 -1
  266. package/dist/worker-lease.d.ts.map +1 -1
  267. package/dist/worker-lease.js +34 -4
  268. package/dist/worker-lease.js.map +1 -1
  269. package/dist/worker.d.ts +8 -0
  270. package/dist/worker.d.ts.map +1 -1
  271. package/dist/worker.js +17 -9
  272. package/dist/worker.js.map +1 -1
  273. package/package.json +3 -7
  274. package/src/agent-artifacts.ts +2 -10
  275. package/src/agent-cwd.ts +139 -0
  276. package/src/artifact-scan-policy.ts +51 -0
  277. package/src/authored-completion-record.ts +119 -0
  278. package/src/authored-completion.ts +203 -0
  279. package/src/authored-flow-error.ts +13 -1
  280. package/src/authored-flow-executor.ts +91 -58
  281. package/src/authored-human.ts +1 -4
  282. package/src/authored-node-entry.ts +4 -1
  283. package/src/authored-node-runner.ts +96 -22
  284. package/src/authored-preflight.ts +46 -0
  285. package/src/authored-root.ts +20 -2
  286. package/src/authored-step-output.ts +139 -42
  287. package/src/authored-verdict.ts +87 -0
  288. package/src/authored-worker-step.ts +63 -14
  289. package/src/cli/check-worker-surface.ts +66 -0
  290. package/src/cli/check.ts +59 -166
  291. package/src/cli/cli-probe.ts +194 -0
  292. package/src/cli/cloud-format.ts +82 -0
  293. package/src/cli/cloud-live.ts +366 -0
  294. package/src/cli/cloud-mirror-session.ts +329 -0
  295. package/src/cli/cloud-read.ts +13 -282
  296. package/src/cli/cloud-refusal.ts +113 -0
  297. package/src/cli/cloud-status-view.ts +213 -0
  298. package/src/cli/direct-run.ts +19 -2
  299. package/src/cli/hn-monitor.ts +2 -1
  300. package/src/cli/local-agent-remedy.ts +160 -0
  301. package/src/cli/observer-session.ts +107 -0
  302. package/src/cli/run.ts +233 -28
  303. package/src/cli/status.ts +59 -3
  304. package/src/cli/step-evidence.ts +310 -0
  305. package/src/cli/step-failure.ts +57 -81
  306. package/src/cli-commands.ts +3 -0
  307. package/src/cli.ts +155 -60
  308. package/src/cloud-deploy.ts +26 -8
  309. package/src/cloud-mirror-ledger.ts +161 -0
  310. package/src/cloud-mirror-step.ts +618 -0
  311. package/src/cloud-mirror-transport.ts +261 -0
  312. package/src/cloud-mirror.ts +633 -0
  313. package/src/cloud-read.ts +25 -1
  314. package/src/cloud-run-record.ts +64 -0
  315. package/src/cloud-run.ts +5 -24
  316. package/src/communication/local.ts +2 -1
  317. package/src/communication/worker.ts +13 -5
  318. package/src/compile.ts +28 -6
  319. package/src/direct-input.ts +22 -1
  320. package/src/failure-kinds.ts +104 -2
  321. package/src/flow-requirements.ts +24 -9
  322. package/src/gate-contract.ts +13 -1
  323. package/src/helper-preflight.ts +10 -3
  324. package/src/helper-reference.ts +130 -0
  325. package/src/hosted-extension-runtime.ts +1 -1
  326. package/src/index.ts +2 -0
  327. package/src/journal-client.ts +19 -2
  328. package/src/journal-projection.ts +106 -0
  329. package/src/llm-worker.ts +1 -1
  330. package/src/local-agent.ts +4 -2
  331. package/src/named-gate-lowering.ts +35 -5
  332. package/src/named-gate-preflight.ts +47 -0
  333. package/src/named-gates.ts +3 -0
  334. package/src/observer-link.ts +10 -1
  335. package/src/package-version.ts +23 -0
  336. package/src/preflight.ts +38 -7
  337. package/src/protocol.ts +2 -0
  338. package/src/redact.ts +111 -0
  339. package/src/relay-cli.ts +1 -24
  340. package/src/run-projection.ts +247 -0
  341. package/src/shell-word.ts +24 -0
  342. package/src/spec.ts +54 -3
  343. package/src/step-fields.ts +1 -1
  344. package/src/steps-green.ts +168 -0
  345. package/src/validate.ts +35 -8
  346. package/src/worker-lease.ts +35 -5
  347. package/src/worker.ts +27 -11
@@ -0,0 +1,633 @@
1
+ // Mirroring a local run onto the Cloud dashboard, while it runs.
2
+ //
3
+ // A hosted run is watchable because something inside the sandbox reads its
4
+ // journal every few seconds and pushes what it finds. Nothing did that for a
5
+ // run started in a terminal, so a local run was invisible: the same flow, the
6
+ // same journal, the same evidence, and no page to look at. This is that
7
+ // reader, on the other side of the boundary.
8
+ //
9
+ // ## What it reads
10
+ //
11
+ // Only this run's journals. The sandbox reporter scans its whole data
12
+ // directory because a sandbox holds exactly one run; a developer's
13
+ // `~/.relayflowd` holds every run they have ever started, including other
14
+ // people's work in a shared checkout. So the set of journals is *derived*: the
15
+ // root, plus the child journals the root's own authored-step index names. A
16
+ // journal this run did not create is never opened, let alone uploaded.
17
+ //
18
+ // ## What it costs a run
19
+ //
20
+ // Nothing that can fail the run. Every push collapses to a boolean at the
21
+ // transport (`cloud-mirror-transport.ts`), each poll is bounded by its own
22
+ // deadline, and a poll that overruns is abandoned rather than allowed to grow
23
+ // with the flow. A journal that cannot be read keeps the previously cached
24
+ // view for that journal: a snapshot never gets emptier because a read failed.
25
+ //
26
+ // ## Ordering at the end
27
+ //
28
+ // The terminal callback is last, and that is load-bearing. Cloud revokes the
29
+ // run's credential when the run goes terminal, so the transcripts and the
30
+ // final step rows have to land before it — otherwise the mirror would report
31
+ // the run finished and then find itself unable to say what it did.
32
+
33
+ import { open, readFile, stat } from 'node:fs/promises';
34
+ import { isAbsolute, join, relative as relative_, resolve } from 'node:path';
35
+ import { walkJournal, JournalReadError, type JournalEvent } from './journal-reader.js';
36
+ import {
37
+ fitSnapshot, mirrorJournal, withGraphHints,
38
+ DEPENDS_ON_MAX_ENTRIES, SNAPSHOT_DEPENDS_ON_MAX, MAX_INT32,
39
+ type FinalStep, type SnapshotStep,
40
+ } from './cloud-mirror-step.js';
41
+ import { MirrorClient, type MirrorEvent } from './cloud-mirror-transport.js';
42
+ import { redact } from './redact.js';
43
+
44
+ /** How often the journals are re-read. The sandbox reporter's own cadence. */
45
+ export const MIRROR_POLL_INTERVAL_MS = 10_000;
46
+ /** Whole-poll budget, independent of how many journals exist. */
47
+ export const MIRROR_POLL_BUDGET_MS = 8_000;
48
+ /** Budget for the final poll and every push that follows it. */
49
+ export const MIRROR_FINISH_BUDGET_MS = 30_000;
50
+ /** Republish an unchanged view this often, so a stalled view still looks fresh. */
51
+ export const MIRROR_SNAPSHOT_HEARTBEAT_MS = 120_000;
52
+ /** Final step rows one report carries; the rest are counted, not sent. */
53
+ export const MIRROR_MAX_FINAL_STEPS = 256;
54
+ /** Step views held across polls. Bounds the mirror, not the run. */
55
+ export const MIRROR_STEP_CACHE_MAX = 512;
56
+ /** Assembled `<stepName>/agent.log` cap, as the hosted executor uses. */
57
+ export const MIRROR_TRANSCRIPT_MAX_BYTES = 1024 * 1024;
58
+ /** Steps whose transcripts are uploaded. Beyond this the rows still land. */
59
+ export const MIRROR_MAX_TRANSCRIPT_UPLOADS = 64;
60
+ /** The run's own `runner.log`, as the `/logs` route serves it. */
61
+ export const MIRROR_RUNNER_LOG_MAX_BYTES = 256 * 1024;
62
+ /**
63
+ * Re-reads of a journal a writer was mid-flight in, and the wait between.
64
+ *
65
+ * `walkJournal` copies the file and refuses a torn snapshot as `journal_busy`,
66
+ * which on a *live* run is the ordinary case, not a fault — `flows status`
67
+ * has always retried it for exactly that reason. Without the same policy the
68
+ * mirror simply skipped a busy journal: a resume could not read the spec it
69
+ * was about to register, so it registered nothing at all and the run stayed
70
+ * off the dashboard.
71
+ */
72
+ export const MIRROR_BUSY_RETRIES = 5;
73
+ export const MIRROR_BUSY_DELAY_MS = 50;
74
+ /** Journals one mirror will follow: the root, plus the children it admitted. */
75
+ export const MIRROR_MAX_JOURNALS = 4096;
76
+
77
+ export interface RunMirrorOptions {
78
+ client: MirrorClient;
79
+ /** The daemon data directory holding this run's journals. */
80
+ dataDir: string;
81
+ /** One line about the mirror itself; never about the run. */
82
+ diagnostic?: (message: string) => void;
83
+ env?: NodeJS.ProcessEnv;
84
+ now?: () => number;
85
+ intervalMs?: number;
86
+ pollBudgetMs?: number;
87
+ snapshotHeartbeatMs?: number;
88
+ /** Test seam: read one journal's events. */
89
+ readJournal?: (runId: string, dataDir: string) => Promise<JournalEvent[]>;
90
+ /** Test seam: read a transcript file. */
91
+ readTranscript?: (path: string) => Promise<{ bytes: Buffer; size: number }>;
92
+ }
93
+
94
+ /** What the mirror was told about the run when it ended. */
95
+ export interface RunMirrorOutcome {
96
+ status: 'completed' | 'failed' | 'cancelled';
97
+ /**
98
+ * The run's own completion report, as the terminal callback stores it.
99
+ *
100
+ * Not decoration. Cloud reconciles a v2 run's reported status against this
101
+ * document and records `failed` for anything that does not prove success —
102
+ * the guard that stops a sandbox whose bootstrap exited cleanly from
103
+ * reporting a run that died at step 1 as green. A mirror that sent a status
104
+ * without the report was reconciled the other way, and a finished local run
105
+ * showed up red. The run list also reads `completionReason` and
106
+ * `pullRequestUrl` straight out of it.
107
+ */
108
+ result: Record<string, unknown>;
109
+ completionReason?: string;
110
+ error?: string;
111
+ /** Lines the CLI printed for this run, uploaded as `runner.log`. */
112
+ log?: readonly string[];
113
+ }
114
+
115
+ export interface RunMirror {
116
+ readonly runId: string;
117
+ readonly runUrl: string;
118
+ /** Begin polling. The first poll runs on the first interval, not now. */
119
+ start(rootRunId: string): void;
120
+ /** One lifecycle event, pushed without waiting for the next poll. */
121
+ event(event: MirrorEvent): void;
122
+ /**
123
+ * Stop polling, take one last reading, and publish the final report, the
124
+ * transcripts and the terminal status — in that order. Never throws, and is
125
+ * bounded by {@link MIRROR_FINISH_BUDGET_MS} however much is outstanding.
126
+ */
127
+ finish(outcome: RunMirrorOutcome): Promise<void>;
128
+ }
129
+
130
+ interface CachedStep {
131
+ step: SnapshotStep;
132
+ /** Journal order within its journal, so the published view reads top to bottom. */
133
+ order: number;
134
+ /** When this step's meaningful content last changed, for eviction. */
135
+ changedAt: number;
136
+ }
137
+
138
+ /**
139
+ * One consistent read of a journal, retrying only the mid-write case.
140
+ *
141
+ * Shared by the poller and by the resume path that reads a run's spec, so
142
+ * both treat a writer being mid-flight the way `flows status` does. Every
143
+ * other failure propagates unchanged: a corrupt journal is not a busy one.
144
+ */
145
+ export async function readJournalEvents(
146
+ runId: string,
147
+ dataDir: string,
148
+ sleep: (ms: number) => Promise<void> = ms => new Promise(done => { setTimeout(done, ms); }),
149
+ ): Promise<JournalEvent[]> {
150
+ for (let attempt = 1; ; attempt += 1) {
151
+ try {
152
+ const events: JournalEvent[] = [];
153
+ for await (const event of walkJournal(runId, dataDir)) events.push(event);
154
+ return events;
155
+ } catch (error) {
156
+ const busy = error instanceof JournalReadError && error.code === 'journal_busy';
157
+ if (!busy || attempt >= MIRROR_BUSY_RETRIES) throw error;
158
+ await sleep(MIRROR_BUSY_DELAY_MS);
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Where a transcript for `runId` is allowed to live: under this data
165
+ * directory's own tree for that run, and nowhere else.
166
+ *
167
+ * The path comes out of the journal, which the worker wrote — so it is not
168
+ * hostile input in the ordinary case. But a journal is a file on a developer's
169
+ * disk, this reader uploads whatever it is handed, and "reads only this run's
170
+ * journals" has to mean the files too. An unconfined path turns a crafted or
171
+ * corrupted journal into an arbitrary-file upload, which is a much worse
172
+ * failure than a missing transcript.
173
+ */
174
+ export function transcriptRoot(dataDir: string, runId: string): string {
175
+ return join(resolve(dataDir), 'runs', runId);
176
+ }
177
+
178
+ /** True when `path` resolves inside `root` — a prefix test that `..` cannot pass. */
179
+ export function withinRoot(root: string, path: string): boolean {
180
+ const relative = relative_(root, resolve(path));
181
+ return relative.length > 0 && !relative.startsWith('..') && !isAbsolute(relative);
182
+ }
183
+
184
+ async function defaultReadTranscript(path: string): Promise<{ bytes: Buffer; size: number }> {
185
+ const info = await stat(path);
186
+ if (!info.isFile()) throw new Error('transcript is not a regular file');
187
+ // Bound the read, not just the result: materializing a huge file only to
188
+ // discard all but the tail can exhaust the CLI before it reports at all.
189
+ if (info.size <= MIRROR_TRANSCRIPT_MAX_BYTES) {
190
+ return { bytes: await readFile(path), size: info.size };
191
+ }
192
+ const handle = await open(path, 'r');
193
+ try {
194
+ const buffer = Buffer.alloc(MIRROR_TRANSCRIPT_MAX_BYTES);
195
+ const { bytesRead } = await handle.read(
196
+ buffer, 0, MIRROR_TRANSCRIPT_MAX_BYTES, info.size - MIRROR_TRANSCRIPT_MAX_BYTES,
197
+ );
198
+ return { bytes: buffer.subarray(0, bytesRead), size: info.size };
199
+ } finally {
200
+ await handle.close();
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Assemble one step's attempts into the single object `/logs` serves.
206
+ *
207
+ * The frame vocabulary is Cloud's: each kept attempt is preceded by a
208
+ * `relayflow.attempt` marker and a dropped one leaves a
209
+ * `relayflow.attempt.omitted` marker in its place, so the dashboard's renderer
210
+ * and `flows logs --step` read a mirrored transcript exactly as they read a
211
+ * hosted one. A reader that sees fewer attempts than ran has been lied to.
212
+ */
213
+ export async function assembleTranscript(
214
+ attempts: ReadonlyArray<{ attempt: number; path: string }>,
215
+ read: (path: string) => Promise<{ bytes: Buffer; size: number }>,
216
+ maxBytes = MIRROR_TRANSCRIPT_MAX_BYTES,
217
+ ): Promise<{ bytes: Buffer; kept: number; omitted: number; truncated: boolean }> {
218
+ const ordered = [...attempts].sort((left, right) => left.attempt - right.attempt);
219
+ const marker = (value: Record<string, unknown>): Buffer => Buffer.from(`${JSON.stringify(value)}\n`, 'utf8');
220
+ const parts: Buffer[] = [];
221
+ let total = 0;
222
+ let kept = 0;
223
+ let omitted = 0;
224
+ let truncated = false;
225
+ // Newest first: the last attempt is what the run's outcome rests on.
226
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
227
+ const { attempt, path } = ordered[index]!;
228
+ let read_: { bytes: Buffer; size: number };
229
+ try {
230
+ read_ = await read(path);
231
+ } catch {
232
+ // A missing attempt file is still an attempt that happened.
233
+ parts.unshift(marker({ type: 'relayflow.attempt.omitted', attempt, bytes: 0 }));
234
+ total += parts[0]!.length;
235
+ omitted += 1;
236
+ continue;
237
+ }
238
+ const reserve = 128 * (index + 1);
239
+ if (kept > 0 && read_.bytes.length > maxBytes - total - reserve) {
240
+ parts.unshift(marker({ type: 'relayflow.attempt.omitted', attempt, bytes: read_.size }));
241
+ total += parts[0]!.length;
242
+ omitted += 1;
243
+ continue;
244
+ }
245
+ let content = read_.bytes;
246
+ let cut = read_.size > content.length;
247
+ const cap = maxBytes - reserve;
248
+ if (content.length > cap) {
249
+ content = content.subarray(content.length - cap);
250
+ cut = true;
251
+ }
252
+ if (cut) {
253
+ // A tail cut lands mid-frame and this object is parsed as JSONL: open on
254
+ // the first WHOLE frame rather than a broken one. A file with no newline
255
+ // is one frame, so there is nothing to snap to and it is left as it is.
256
+ const firstFrame = content.indexOf(0x0a);
257
+ if (firstFrame >= 0 && firstFrame + 1 < content.length) content = content.subarray(firstFrame + 1);
258
+ truncated = true;
259
+ }
260
+ const head = marker({ type: 'relayflow.attempt', attempt, bytes: content.length, truncated: cut });
261
+ const body = content.length > 0 && content[content.length - 1] !== 0x0a
262
+ ? Buffer.concat([content, Buffer.from('\n')])
263
+ : content;
264
+ parts.unshift(head, body);
265
+ total += head.length + body.length;
266
+ kept += 1;
267
+ }
268
+ return { bytes: Buffer.concat(parts), kept, omitted, truncated };
269
+ }
270
+
271
+ export function createRunMirror(options: RunMirrorOptions): RunMirror {
272
+ const env = options.env ?? process.env;
273
+ const now = options.now ?? Date.now;
274
+ const intervalMs = options.intervalMs ?? MIRROR_POLL_INTERVAL_MS;
275
+ const pollBudgetMs = options.pollBudgetMs ?? MIRROR_POLL_BUDGET_MS;
276
+ const heartbeatMs = options.snapshotHeartbeatMs ?? MIRROR_SNAPSHOT_HEARTBEAT_MS;
277
+ const readJournal = options.readJournal ?? readJournalEvents;
278
+ const readTranscript = options.readTranscript ?? defaultReadTranscript;
279
+ const diagnostic = options.diagnostic ?? ((): void => {});
280
+
281
+ /** The journals this run owns: the root, and every child its index names. */
282
+ const journals: string[] = [];
283
+ const known = new Set<string>();
284
+ const finished = new Set<string>();
285
+ const steps = new Map<string, CachedStep>();
286
+ /** Final rows by `<journal>/<step>`, so two journals cannot collide on a step id. */
287
+ const finals = new Map<string, { journalRunId: string; row: FinalStep }>();
288
+ const transcripts = new Map<string, { stepName: string; attempts: Array<{ attempt: number; path: string }> }>();
289
+ const hints = new Map<string, { label?: string; after?: string[] }>();
290
+ /** Live steps the cache cap dropped, by identity, so a capped view owns up to it. */
291
+ const evicted = new Set<string>();
292
+ /** Finished steps past the report cap, by identity. Counted, never sent. */
293
+ const unreported = new Set<string>();
294
+ let timer: ReturnType<typeof setInterval> | undefined;
295
+ /**
296
+ * The poll currently running, if one is. `clearInterval` stops future polls
297
+ * but not one already in flight: that poll shares the step cache and the
298
+ * sequence counter with `finish`, and Cloud revokes the run token at the
299
+ * terminal callback — so a poll that outlived the finish could publish a
300
+ * stale view it can never repair, or none at all.
301
+ */
302
+ let inFlight: Promise<void> | undefined;
303
+ let stopped = false;
304
+ /** Set immediately before the terminal callback; nothing may publish after it. */
305
+ let terminalReported = false;
306
+ let sequence = 0;
307
+ let acknowledged: string | undefined;
308
+ let acknowledgedAt = now();
309
+
310
+ const admit = (runId: string): void => {
311
+ // Far above any real authored run, and a bound all the same: the child
312
+ // list comes out of a journal, and a reader of a journal bounds what it
313
+ // will grow to hold.
314
+ if (known.has(runId) || journals.length >= MIRROR_MAX_JOURNALS) return;
315
+ known.add(runId);
316
+ journals.push(runId);
317
+ };
318
+
319
+ const remember = (journalRunId: string, step: SnapshotStep, order: number): void => {
320
+ const key = `${journalRunId}/${step.stepName}`;
321
+ const existing = steps.get(key);
322
+ const changed = existing === undefined || fingerprint(existing.step) !== fingerprint(step);
323
+ steps.set(key, { step, order, changedAt: changed ? now() : existing!.changedAt });
324
+ evicted.delete(key);
325
+ while (steps.size > MIRROR_STEP_CACHE_MAX) {
326
+ // Evict the least recently changed *completed* step first: the live work
327
+ // is the whole point of a live view.
328
+ const victim = [...steps.entries()].sort(comparePriority).pop();
329
+ if (victim === undefined) break;
330
+ steps.delete(victim[0]);
331
+ evicted.add(victim[0]);
332
+ }
333
+ };
334
+
335
+ const scan = async (deadline: number): Promise<void> => {
336
+ // Index-based, over the live array: a child journal the root's index names
337
+ // is appended during this very pass, and a run whose only steps are its
338
+ // children would otherwise publish nothing until the next poll — which for
339
+ // a short run is never, because `finish` scans once.
340
+ for (let index = 0; index < journals.length; index += 1) {
341
+ const runId = journals[index]!;
342
+ if (now() > deadline) break;
343
+ if (finished.has(runId)) continue;
344
+ let events: JournalEvent[];
345
+ try {
346
+ events = await readJournal(runId, options.dataDir);
347
+ } catch (error) {
348
+ // Two ordinary conditions, neither worth a line on someone's terminal
349
+ // once a poll: `run_not_found`, because a child journal is named in
350
+ // the index the moment its step is admitted and that can precede the
351
+ // file; and `journal_busy` past its retries, because a hot journal is
352
+ // what a running flow looks like. Both keep the cached view.
353
+ const ordinary = error instanceof JournalReadError
354
+ && (error.code === 'run_not_found' || error.code === 'journal_busy');
355
+ if (!ordinary) {
356
+ diagnostic(`could not read journal ${runId}: ${error instanceof Error ? error.message : String(error)}`);
357
+ }
358
+ continue;
359
+ }
360
+ let folded;
361
+ try {
362
+ folded = mirrorJournal(runId, events, now(), env);
363
+ } catch (error) {
364
+ diagnostic(`could not fold journal ${runId}: ${error instanceof Error ? error.message : String(error)}`);
365
+ continue;
366
+ }
367
+ for (const child of folded.children) admit(child);
368
+ for (const [key, hint] of folded.hints) {
369
+ // Merge, never replace: a completion record can omit a label or an
370
+ // edge the admission carried, and the view must not lose a step's
371
+ // name between them.
372
+ const existing = hints.get(key);
373
+ hints.set(key, {
374
+ ...(hint.label ?? existing?.label ? { label: hint.label ?? existing?.label } : {}),
375
+ ...(hint.after ?? existing?.after ? { after: hint.after ?? existing?.after } : {}),
376
+ });
377
+ }
378
+ folded.steps.forEach((step, order) => remember(runId, step, order));
379
+ for (const row of folded.finals) {
380
+ const key = `${runId}/${row.stepName}`;
381
+ // The report cap bounds what this process holds, not just what it
382
+ // sends: a run with ten thousand steps must not grow its own mirror.
383
+ // Steps past it are counted by identity, so a step read twice is one
384
+ // missing step and not two.
385
+ if (!finals.has(key) && finals.size >= MIRROR_MAX_FINAL_STEPS) {
386
+ unreported.add(key);
387
+ continue;
388
+ }
389
+ finals.set(key, { journalRunId: runId, row });
390
+ }
391
+ for (const ref of folded.transcripts) {
392
+ if (!finals.has(`${runId}/${ref.stepName}`)) continue;
393
+ // Only files under this run's own tree. A journal names its transcript
394
+ // paths, and this reader uploads what it is handed, so the confinement
395
+ // belongs here rather than in the reader's caller.
396
+ const root = transcriptRoot(options.dataDir, runId);
397
+ const attempts = ref.attempts.filter(attempt => withinRoot(root, attempt.path));
398
+ if (attempts.length < ref.attempts.length) {
399
+ diagnostic(`ignored ${ref.attempts.length - attempts.length} transcript path(s) outside ${runId}'s own tree`);
400
+ }
401
+ if (attempts.length === 0) continue;
402
+ transcripts.set(`${runId}/${ref.stepName}`, { stepName: ref.stepName, attempts });
403
+ }
404
+ if (folded.terminal) finished.add(runId);
405
+ }
406
+ };
407
+
408
+ /** The cached view in display order: journal order, journal by journal. */
409
+ const ordered = (): SnapshotStep[] => {
410
+ const names = new Set([...steps.values()].map(entry => entry.step.stepName));
411
+ return [...steps.entries()]
412
+ .sort(([leftKey, left], [rightKey, right]) => {
413
+ const journal = leftKey.split('/')[0]!.localeCompare(rightKey.split('/')[0]!);
414
+ return journal !== 0 ? journal : left.order - right.order;
415
+ })
416
+ .map(([, entry]) => withGraphHints(
417
+ entry.step, hints, entry.step.journalRunId, SNAPSHOT_DEPENDS_ON_MAX, names,
418
+ ));
419
+ };
420
+
421
+ const publishSnapshot = async (deadline: number): Promise<void> => {
422
+ // The invariant is narrow and exact: nothing publishes after the terminal
423
+ // callback, which revokes the credential and must be last. `finish` stops
424
+ // *waiting* for an in-flight poll once its deadline passes, so that poll can
425
+ // still be running when the callback goes out — and a view arriving then is
426
+ // worse than a missing one, because the final rows have already replaced it.
427
+ //
428
+ // Deliberately not gated on "finish has begun": finish takes its own last
429
+ // reading first, and that is the snapshot showing the run's final moments.
430
+ if (terminalReported) return;
431
+ const view = ordered();
432
+ if (view.length === 0) return;
433
+ // `elapsedMs` moves every poll and is excluded from the comparison, or the
434
+ // throttle would degenerate into "push every interval".
435
+ const print = view.map(fingerprint).join('|');
436
+ if (print === acknowledged && now() - acknowledgedAt < heartbeatMs) return;
437
+ sequence += 1;
438
+ const snapshot = fitSnapshot(view, {
439
+ sequence,
440
+ capturedAt: new Date(now()).toISOString(),
441
+ alreadyOmitted: evicted.size,
442
+ });
443
+ const budget = deadline - now();
444
+ if (budget <= 0) return;
445
+ if (await options.client.publishSnapshot(snapshot, { timeoutMs: budget })) {
446
+ acknowledged = print;
447
+ acknowledgedAt = now();
448
+ }
449
+ };
450
+
451
+ const poll = async (budgetMs: number): Promise<void> => {
452
+ if (inFlight !== undefined || stopped) return;
453
+ let settle = (): void => {};
454
+ inFlight = new Promise<void>(done => { settle = done; });
455
+ try {
456
+ const deadline = now() + budgetMs;
457
+ await scan(deadline);
458
+ // Finish takes its own reading; a poll racing it adds nothing.
459
+ if (!stopped) await publishSnapshot(deadline);
460
+ } catch (error) {
461
+ diagnostic(`poll failed: ${error instanceof Error ? error.message : String(error)}`);
462
+ } finally {
463
+ const done = inFlight;
464
+ inFlight = undefined;
465
+ settle();
466
+ void done;
467
+ }
468
+ };
469
+
470
+ /**
471
+ * The budget left, as a per-request timeout.
472
+ *
473
+ * `finish` had a deadline it only checked *between* phases, while every
474
+ * request underneath carried its own 30-second timeout — so a stalled Cloud
475
+ * could hold a finished local run open for minutes, and the CLI awaits this
476
+ * before returning the run's exit code. Handing each request the remaining
477
+ * budget makes the deadline mean what it says.
478
+ */
479
+ const remaining = (deadline: number): number => Math.max(1, deadline - now());
480
+
481
+ /**
482
+ * The object name for one step's transcript, unique across journals.
483
+ *
484
+ * The caches here are keyed `<journal>/<step>` precisely because child
485
+ * journals can repeat a kernel step id — and then the object key and the
486
+ * row's `sandboxId` used only the step id, so two steps overwrote each
487
+ * other's log and the run page showed one step's transcript for both.
488
+ *
489
+ * Only a genuine collision is disambiguated. The common run has one journal
490
+ * per step, and keeping the bare step id there means a mirrored transcript is
491
+ * named exactly as a hosted one is.
492
+ */
493
+ const transcriptName = (journalRunId: string, stepName: string): string => {
494
+ const repeated = [...finals.keys()].filter(key => key.endsWith(`/${stepName}`)).length > 1;
495
+ return repeated ? `${journalRunId}.${stepName}` : stepName;
496
+ };
497
+
498
+ const uploadTranscripts = async (deadline: number): Promise<void> => {
499
+ let uploaded = 0;
500
+ for (const [key, ref] of transcripts) {
501
+ if (uploaded >= MIRROR_MAX_TRANSCRIPT_UPLOADS || now() > deadline) break;
502
+ const entry = finals.get(key);
503
+ if (entry === undefined) continue;
504
+ const objectName = transcriptName(entry.journalRunId, ref.stepName);
505
+ let assembled;
506
+ try {
507
+ assembled = await assembleTranscript(ref.attempts, readTranscript);
508
+ } catch (error) {
509
+ diagnostic(`could not assemble the transcript for ${ref.stepName}: ${error instanceof Error ? error.message : String(error)}`);
510
+ continue;
511
+ }
512
+ if (assembled.bytes.length === 0) continue;
513
+ // Name the row after the object only once the object is there, so no row
514
+ // ever points at a transcript that was never written.
515
+ if (await options.client.putObject(`${objectName}/agent.log`, assembled.bytes,
516
+ undefined, remaining(deadline))) {
517
+ finals.set(key, { ...entry, row: { ...entry.row, sandboxId: objectName } });
518
+ uploaded += 1;
519
+ }
520
+ }
521
+ };
522
+
523
+ const publishFinal = async (deadline: number): Promise<void> => {
524
+ const sent = [...finals.values()];
525
+ // A capped report says how many steps it left out. A run page that shows
526
+ // 256 of 400 steps without saying so is worse than one that shows none.
527
+ const omitted = Math.min(unreported.size, MAX_INT32);
528
+ const names = new Set(sent.map(entry => entry.row.stepName));
529
+ const withGraph = sent.map(entry =>
530
+ withGraphHints(entry.row, hints, entry.journalRunId, DEPENDS_ON_MAX_ENTRIES, names));
531
+ if (now() > deadline) return;
532
+ if (!await options.client.publishSteps(withGraph, omitted, undefined, remaining(deadline))) {
533
+ diagnostic('Cloud did not accept the final step report; the run page keeps its live view');
534
+ }
535
+ };
536
+
537
+ return {
538
+ get runId() { return options.client.runId; },
539
+ get runUrl() { return options.client.runUrl; },
540
+ start(rootRunId) {
541
+ admit(rootRunId);
542
+ if (timer !== undefined || stopped) return;
543
+ timer = setInterval(() => { void poll(pollBudgetMs); }, intervalMs);
544
+ // Never hold the process open for an observation.
545
+ timer.unref?.();
546
+ },
547
+ event(event) {
548
+ void options.client.publishEvent(event);
549
+ },
550
+ async finish(outcome) {
551
+ if (timer !== undefined) { clearInterval(timer); timer = undefined; }
552
+ const deadline = now() + MIRROR_FINISH_BUDGET_MS;
553
+ try {
554
+ // Let an in-flight poll settle before touching the shared cache, and
555
+ // stop it publishing afterwards. Bounded by the same deadline as
556
+ // everything else here, so a wedged poll cannot hold the CLI open.
557
+ if (inFlight !== undefined) {
558
+ await Promise.race([
559
+ inFlight,
560
+ new Promise<void>(done => { setTimeout(done, remaining(deadline)).unref?.(); }),
561
+ ]);
562
+ }
563
+ stopped = true;
564
+ // One last reading, so the page shows the run's actual last moments
565
+ // rather than whatever the previous poll happened to catch.
566
+ await scan(deadline);
567
+ await publishSnapshot(deadline);
568
+ if (outcome.log !== undefined && outcome.log.length > 0) {
569
+ // The CLI's own output, redacted on the way out. It is not a
570
+ // transcript the worker already scrubbed: it is whatever this
571
+ // invocation printed on a developer's machine, including diagnostics
572
+ // that can quote a command line or an environment value.
573
+ const bytes = Buffer.from(redact(outcome.log.join('\n'), env), 'utf8');
574
+ await options.client.putObject('runner.log',
575
+ bytes.length > MIRROR_RUNNER_LOG_MAX_BYTES
576
+ ? bytes.subarray(bytes.length - MIRROR_RUNNER_LOG_MAX_BYTES)
577
+ : bytes,
578
+ undefined, remaining(deadline));
579
+ }
580
+ await uploadTranscripts(deadline);
581
+ await publishFinal(deadline);
582
+ // Last, always: this transition revokes the credential every call
583
+ // above depends on.
584
+ terminalReported = true;
585
+ await options.client.reportTerminal(
586
+ outcome.status,
587
+ redactJson(outcome.result, env) as Record<string, unknown>,
588
+ outcome.error,
589
+ undefined,
590
+ remaining(deadline),
591
+ );
592
+ } catch (error) {
593
+ diagnostic(`could not finish the mirror: ${error instanceof Error ? error.message : String(error)}`);
594
+ } finally {
595
+ stopped = true;
596
+ }
597
+ },
598
+ };
599
+ }
600
+
601
+ /**
602
+ * Redact every string the report carries, leaf by leaf.
603
+ *
604
+ * Not `redact(JSON.stringify(...))`: the redactor's value patterns end in
605
+ * `\\S+`, which across serialized JSON would swallow the closing quote and the
606
+ * next key, and hand Cloud a document it cannot parse. Redacting leaves keeps
607
+ * the shape intact and still scrubs every free-text field.
608
+ */
609
+ function redactJson(value: unknown, env: NodeJS.ProcessEnv): unknown {
610
+ if (typeof value === 'string') return redact(value, env);
611
+ if (Array.isArray(value)) return value.map(entry => redactJson(entry, env));
612
+ if (value !== null && typeof value === 'object') {
613
+ return Object.fromEntries(
614
+ Object.entries(value as Record<string, unknown>).map(([key, entry]) => [key, redactJson(entry, env)]),
615
+ );
616
+ }
617
+ return value;
618
+ }
619
+
620
+ /** Everything about a step but its ever-moving elapsed time. */
621
+ function fingerprint(step: SnapshotStep): string {
622
+ const { elapsedMs: _elapsedMs, ...rest } = step;
623
+ return JSON.stringify(rest);
624
+ }
625
+
626
+ /** Least recently changed first, and a running step is never the first victim. */
627
+ function comparePriority(
628
+ [, left]: [string, CachedStep],
629
+ [, right]: [string, CachedStep],
630
+ ): number {
631
+ const liveness = Number(left.step.state === 'done') - Number(right.step.state === 'done');
632
+ return liveness !== 0 ? liveness : right.changedAt - left.changedAt;
633
+ }
package/src/cloud-read.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // key is ignored, a route that drops one yields `null`, never a throw.
16
16
 
17
17
  import { CloudFlowError, cloudFetch, cloudRunId, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
18
+ import { cloudRunState, type CloudRunState } from './cloud-run-record.js';
18
19
 
19
20
  /** One row of `GET /api/v1/workflows/runs`. */
20
21
  export interface CloudRunSummary {
@@ -334,8 +335,31 @@ export async function getCloudRunDetail(
334
335
  options: CloudConnectionOptions = {},
335
336
  ): Promise<CloudRunDetail> {
336
337
  const asked = callerRunId(runId);
337
- const body = record(await cloudFetch(`/api/v1/workflows/runs/${asked}`, options,
338
+ return runDetail(asked, await cloudFetch(`/api/v1/workflows/runs/${asked}`, options,
338
339
  { method: 'GET', detail: true }));
340
+ }
341
+
342
+ /**
343
+ * The run record twice over: what to render, and what it attests.
344
+ *
345
+ * `--watch` and `--follow` need both, from the same bytes. The rendered view
346
+ * is the permissive mapping below, which shows whatever Cloud said; the exit
347
+ * code comes from `cloudRunState`, the same validator `flows run --wait`
348
+ * blocks on, so a live read cannot exit 0 on a record the waiter refuses.
349
+ * One request, because a second GET per poll could observe a different run
350
+ * than the one that was drawn.
351
+ */
352
+ export async function getCloudRunDetailLive(
353
+ runId: string,
354
+ options: CloudConnectionOptions = {},
355
+ ): Promise<{ detail: CloudRunDetail; state: CloudRunState }> {
356
+ const asked = callerRunId(runId);
357
+ const answered = await cloudFetch(`/api/v1/workflows/runs/${asked}`, options, { method: 'GET', detail: true });
358
+ return { detail: runDetail(asked, answered), state: cloudRunState(answered, asked) };
359
+ }
360
+
361
+ function runDetail(asked: string, answeredBody: unknown): CloudRunDetail {
362
+ const body = record(answeredBody);
339
363
  if (body === null) throw new CloudFlowError('invalid_response', 'Cloud returned no run record.');
340
364
  // The record has to be the record that was asked for, and it has to say what
341
365
  // the run is doing. `getCloudFlowRun` (cloud-run.ts) already refuses a