@tanstack/ai-sandbox 0.2.3 → 0.3.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 (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Bound the journal directory: delete the journals nobody will ever read again,
3
+ * and — far more importantly — refuse to delete anything else.
4
+ *
5
+ * `journalCleanupCommand` already deletes ONE run's journal at the moment its
6
+ * `{"__exit":N}` sentinel is observed. That covers every run a host watched to
7
+ * completion and covers nothing else: a run that reaches its sentinel while
8
+ * DETACHED has no host reading its journal, so nothing observes the sentinel and
9
+ * nothing calls the cleanup. Those journals accumulate in
10
+ * {@link DEFAULT_JOURNAL_DIR} until the sandbox dies, which on a `keepAlive`
11
+ * sandbox may be never. This module is the sweep that bounds them, driven from a
12
+ * cron or a reaper rather than from a run.
13
+ *
14
+ * **Why deleting is dangerous, and therefore why almost every branch keeps.**
15
+ * The journal is the ONLY copy of the bytes a successor host needs to replay a
16
+ * run a dead host abandoned mid-flight. Delete a live run's journal and that run
17
+ * becomes unresumable — silently, because the reader will simply deliver nothing.
18
+ * There is no undo and no second copy. So the decision procedure here is not
19
+ * "delete unless I have a reason to keep"; it is the opposite, and every arm that
20
+ * is not a PROVEN-safe deletion keeps:
21
+ *
22
+ * | the store says… | action | why |
23
+ * | ---------------------------------- | ------ | --- |
24
+ * | terminal (`isTerminalRunStatus`) | DELETE | the delivery log, not the journal, is the record |
25
+ * | non-terminal, INCLUDING `'interrupted'` | KEEP | an interrupt-resume continues from it |
26
+ * | nothing (unknown runId) | KEEP until `orphanTtlMs` | the reader creates the journal BEFORE the record exists |
27
+ * | the lookup threw | KEEP | never delete on an unanswered question |
28
+ * | (the name did not decode) | KEEP | a truncated name decodes to a plausible WRONG runId |
29
+ * | (no mtime listing) | KEEP every age-gated entry | cannot age-gate ⇒ cannot expire |
30
+ *
31
+ * Deleting a TERMINAL run's journal is safe because a late takeover of a terminal
32
+ * run aligns against the delivery LOG, not the journal: `align.ts`'s
33
+ * `alignToStoredLog` takes a `StreamDurability` plus an
34
+ * `AsyncIterable<StreamChunk>`, has no `SandboxHandle` and no `JournalPaths` in
35
+ * its signature, and reads the already-delivered prefix with
36
+ * `durability.snapshot()`. It *cannot* read a journal, so removing one cannot
37
+ * break it. A non-zero exit is terminal too — `{"__exit":7}` is as final as
38
+ * `{"__exit":0}`.
39
+ *
40
+ * The unknown-runId arm is the subtle one, and it is why an age gate exists at
41
+ * all. `journalFollowCommand` opens the journal with `: >> file`, which CREATES
42
+ * it; the reader and the run record are written by two independent code paths and
43
+ * nothing orders them. So "a journal exists whose runId the store has never heard
44
+ * of" is the NORMAL state of a run that started moments ago, not an anomaly.
45
+ * Treating unknown as deletable would race every single run start. The journal is
46
+ * therefore kept until it has been untouched for `orphanTtlMs`, which is the only
47
+ * evidence available that no one is writing to it.
48
+ *
49
+ * **The fail-closed trap this module exists to not fall into.** BusyBox `find`
50
+ * prints its "unrecognized option" diagnostic to *stderr* and exits **1 with
51
+ * empty stdout**. A capability probe that ignores the exit code reads that as "no
52
+ * files matched", i.e. "no file is newer than the cutoff" — and code that then
53
+ * concludes "therefore every file is old" **deletes the entire directory**, live
54
+ * runs included. {@link parseJournalMtimeListing} is built to make that
55
+ * impossible: it passes the directory as `stat`'s own first operand as a
56
+ * self-witness and returns `{ kind: 'unavailable' }` when that witness line is
57
+ * absent, never `[]`. This module's whole obligation on that front is to honor
58
+ * `unavailable` as "I cannot age-gate, so I keep" rather than as an empty
59
+ * listing. See the `age-gate-unavailable` reason.
60
+ *
61
+ * **Shell only, never `handle.fs.*`.** On local-process, `fs.*` resolves `/tmp`
62
+ * under the sandbox root while a shell redirect hits the real host `/tmp`, so an
63
+ * `fs.remove` would delete a DIFFERENT path than the one `journaledCommand`
64
+ * wrote — silently doing nothing while reporting success. Every filesystem touch
65
+ * here goes through `handle.process.exec` with a command composed in
66
+ * `journal.ts`.
67
+ */
68
+ import { isTerminalRunStatus } from '@tanstack/ai'
69
+ import {
70
+ DEFAULT_JOURNAL_DIR,
71
+ decodeJournalRunId,
72
+ journalCleanupCommand,
73
+ journalListCommand,
74
+ journalMtimeListCommand,
75
+ journalPaths,
76
+ parseJournalMtimeListing,
77
+ } from './journal'
78
+ import type { InternalLogger } from '@tanstack/ai/adapter-internals'
79
+ import type { RunStore } from '@tanstack/ai'
80
+ import type { SandboxHandle } from './contracts'
81
+
82
+ /**
83
+ * How long a journal whose runId the store does not know must go untouched
84
+ * before the sweep will delete it.
85
+ *
86
+ * One hour, chosen against what the window actually protects: the gap between a
87
+ * reader creating the journal with `: >> file` and the run record appearing in
88
+ * the store. That gap is milliseconds in the normal case and seconds in the worst
89
+ * case (a slow store, a retried write). An hour is three orders of magnitude of
90
+ * headroom on the race, while still bounding a leaked journal to something a
91
+ * sandbox's disk survives. Erring long is the cheap direction: the cost of too
92
+ * long is bytes, the cost of too short is a destroyed live run.
93
+ */
94
+ export const DEFAULT_ORPHAN_TTL_MS = 60 * 60 * 1000
95
+
96
+ /**
97
+ * Ceiling on deletions per sweep. A cron-driven sweep runs unattended, so a
98
+ * mistake — a store that answers `terminal` for everything, a misconfigured
99
+ * directory — is bounded by this rather than by how many journals happen to
100
+ * exist. The remainder is reported as kept with reason `max-deletes` and picked
101
+ * up by the next sweep.
102
+ */
103
+ export const DEFAULT_MAX_DELETES = 200
104
+
105
+ /** Why {@link pruneJournals} left a journal in place. */
106
+ export type KeptJournalReason =
107
+ /** The store answered with a non-terminal status (`'running'`, `'interrupted'`). */
108
+ | 'non-terminal'
109
+ /** The store has never heard of this runId and the journal is still fresh. */
110
+ | 'orphan-too-recent'
111
+ /**
112
+ * The store has never heard of this runId and the age gate could not run at
113
+ * all — {@link parseJournalMtimeListing} returned `unavailable`. THE
114
+ * FAIL-CLOSED ARM: an unavailable listing is not an empty one and says nothing
115
+ * about any file's age.
116
+ */
117
+ | 'age-gate-unavailable'
118
+ /**
119
+ * The age gate ran but reported no mtime for this file, so its age is unknown.
120
+ * (A file created between the two `exec`s, or a name the glob missed.)
121
+ */
122
+ | 'age-gate-missing-entry'
123
+ /** {@link decodeJournalRunId} refused the name (`truncated` or `malformed`). */
124
+ | 'undecodable-name'
125
+ /** The store lookup threw. A question that was not answered is not a licence to delete. */
126
+ | 'store-error'
127
+ /** The `rm` itself failed or exited non-zero. */
128
+ | 'delete-failed'
129
+ /** {@link PruneJournalsOptions.maxDeletes} was already reached this sweep. */
130
+ | 'max-deletes'
131
+
132
+ /** One journal (or one runId's journal + sidecar) the sweep declined to delete. */
133
+ export interface KeptJournal {
134
+ /** The decoded runId; absent exactly when `reason` is `'undecodable-name'`. */
135
+ runId?: string
136
+ /** Every listed filename this entry covers — the journal and its `.err` sidecar. */
137
+ names: Array<string>
138
+ reason: KeptJournalReason
139
+ }
140
+
141
+ /** A non-fatal failure the sweep folded into its result instead of throwing. */
142
+ export interface PruneJournalsFailure {
143
+ stage: 'list' | 'mtime-list' | 'store' | 'delete'
144
+ /** Present when the failure is attributable to one run. */
145
+ runId?: string
146
+ message: string
147
+ }
148
+
149
+ /** What one {@link pruneJournals} sweep did. */
150
+ export interface PruneJournalsResult {
151
+ /** Filenames `ls -1` reported, before de-duplication by runId. */
152
+ listed: number
153
+ /** Distinct runIds those filenames decoded to. */
154
+ runIds: number
155
+ /** runIds whose journal AND sidecar were deleted, in the order deleted. */
156
+ deleted: Array<string>
157
+ /** Everything left in place, with the reason. */
158
+ kept: Array<KeptJournal>
159
+ /**
160
+ * Whether the mtime age gate was usable this sweep. `'unavailable'` means no
161
+ * orphan could be expired, by design.
162
+ */
163
+ ageGate: 'listed' | 'unavailable'
164
+ failures: Array<PruneJournalsFailure>
165
+ }
166
+
167
+ export interface PruneJournalsOptions {
168
+ /** Sandbox holding the journal directory. Touched only via `process.exec`. */
169
+ handle: SandboxHandle
170
+ /**
171
+ * Run lookup. Only `get` is used: the sweep asks about the runIds it found on
172
+ * disk and never enumerates the store, so no optional `RunStore` method is
173
+ * required of a backend.
174
+ */
175
+ runs: Pick<RunStore, 'get'>
176
+ /** Journal directory. Defaults to {@link DEFAULT_JOURNAL_DIR}. */
177
+ dir?: string
178
+ /** Age-gate reference time. Defaults to `Date.now()`; injectable for tests. */
179
+ now?: number
180
+ /** See {@link DEFAULT_ORPHAN_TTL_MS}. */
181
+ orphanTtlMs?: number
182
+ /** See {@link DEFAULT_MAX_DELETES}. */
183
+ maxDeletes?: number
184
+ logger?: InternalLogger
185
+ }
186
+
187
+ function errorMessage(error: unknown): string {
188
+ return error instanceof Error ? error.message : String(error)
189
+ }
190
+
191
+ /**
192
+ * Group listed filenames by the runId they decode to, so a journal and its
193
+ * `.err` sidecar are ONE decision and ONE `rm`, not two.
194
+ *
195
+ * De-duplication is not a tidiness measure: `journalCleanupCommand` deletes both
196
+ * paths for a runId at once, so iterating raw names would ask the store twice per
197
+ * run and then issue a second `rm` for files the first one already removed —
198
+ * doubling the store load and reporting one run as two deletions.
199
+ */
200
+ function groupByRunId(names: Array<string>): {
201
+ byRunId: Map<string, Array<string>>
202
+ undecodable: Array<string>
203
+ } {
204
+ const byRunId = new Map<string, Array<string>>()
205
+ const undecodable: Array<string> = []
206
+ for (const name of names) {
207
+ const decoded = decodeJournalRunId(name)
208
+ if (decoded.kind !== 'runId') {
209
+ undecodable.push(name)
210
+ continue
211
+ }
212
+ const existing = byRunId.get(decoded.runId)
213
+ if (existing === undefined) byRunId.set(decoded.runId, [name])
214
+ else existing.push(name)
215
+ }
216
+ return { byRunId, undecodable }
217
+ }
218
+
219
+ /**
220
+ * Sweep the journal directory, deleting only journals whose runs the store
221
+ * reports terminal (plus orphans that have been untouched past `orphanTtlMs`).
222
+ *
223
+ * **Never rejects.** This runs unattended from a cron, where a rejected promise
224
+ * is an unhandled rejection and, worse, hides which journals were and were not
225
+ * swept. Every failure — a listing that errored, a store that threw, an `rm` that
226
+ * exited non-zero — is folded into
227
+ * {@link PruneJournalsResult.failures} and the sweep continues with the entries
228
+ * it can still decide about.
229
+ */
230
+ export async function pruneJournals(
231
+ options: PruneJournalsOptions,
232
+ ): Promise<PruneJournalsResult> {
233
+ const dir = options.dir ?? DEFAULT_JOURNAL_DIR
234
+ const now = options.now ?? Date.now()
235
+ const orphanTtlMs = options.orphanTtlMs ?? DEFAULT_ORPHAN_TTL_MS
236
+ const maxDeletes = options.maxDeletes ?? DEFAULT_MAX_DELETES
237
+ const logger = options.logger
238
+
239
+ const deleted: Array<string> = []
240
+ const kept: Array<KeptJournal> = []
241
+ const failures: Array<PruneJournalsFailure> = []
242
+
243
+ // `ls -1` is the authoritative name list. It is a SEPARATE command from the
244
+ // mtime listing on purpose: `stat -c` may not exist on the provider's
245
+ // busybox, and a sweep that could not enumerate at all when the age gate is
246
+ // unavailable would never delete the terminal journals it is safe to delete.
247
+ let names: Array<string> = []
248
+ try {
249
+ const listing = await options.handle.process.exec(journalListCommand(dir))
250
+ names = listing.stdout
251
+ .split('\n')
252
+ .map((line) => line.trim())
253
+ .filter((line) => line !== '')
254
+ } catch (error) {
255
+ // Nothing was enumerated, so nothing can be deleted. Report and stop —
256
+ // there is no partial-listing arm, because a partial listing is
257
+ // indistinguishable from a complete one and we only ever DELETE from it.
258
+ failures.push({ stage: 'list', message: errorMessage(error) })
259
+ logger?.warn('journal sweep: listing the journal directory failed', {
260
+ dir,
261
+ error,
262
+ })
263
+ return {
264
+ listed: 0,
265
+ runIds: 0,
266
+ deleted,
267
+ kept,
268
+ ageGate: 'unavailable',
269
+ failures,
270
+ }
271
+ }
272
+
273
+ // The age gate. `unavailable` is a first-class outcome, NOT an empty listing:
274
+ // see the module doc's BusyBox `find` trap. It disables orphan expiry for this
275
+ // sweep and disables nothing else.
276
+ let ageGate: 'listed' | 'unavailable' = 'unavailable'
277
+ const mtimes = new Map<string, number>()
278
+ try {
279
+ const probe = await options.handle.process.exec(
280
+ journalMtimeListCommand(dir),
281
+ )
282
+ const parsed = parseJournalMtimeListing(probe.stdout, dir)
283
+ if (parsed.kind === 'listed') {
284
+ ageGate = 'listed'
285
+ for (const entry of parsed.entries) mtimes.set(entry.name, entry.mtimeMs)
286
+ } else {
287
+ logger?.warn(
288
+ 'journal sweep: mtime listing unavailable; keeping every orphan',
289
+ { dir },
290
+ )
291
+ }
292
+ } catch (error) {
293
+ failures.push({ stage: 'mtime-list', message: errorMessage(error) })
294
+ logger?.warn('journal sweep: mtime listing failed; keeping every orphan', {
295
+ dir,
296
+ error,
297
+ })
298
+ }
299
+
300
+ const { byRunId, undecodable } = groupByRunId(names)
301
+
302
+ // Undecodable names are kept unconditionally and without asking the store.
303
+ // A truncated name decodes to a PLAUSIBLE BUT WRONG runId, so consulting the
304
+ // store about it would answer a question about some other run — possibly a
305
+ // live one — and a `terminal` answer would then delete this run's journal.
306
+ for (const name of undecodable) {
307
+ kept.push({ names: [name], reason: 'undecodable-name' })
308
+ }
309
+
310
+ const orphanCutoff = now - orphanTtlMs
311
+
312
+ for (const [runId, runNames] of byRunId) {
313
+ if (deleted.length >= maxDeletes) {
314
+ kept.push({ runId, names: runNames, reason: 'max-deletes' })
315
+ continue
316
+ }
317
+
318
+ let record: Awaited<ReturnType<RunStore['get']>>
319
+ try {
320
+ record = await options.runs.get(runId)
321
+ } catch (error) {
322
+ failures.push({ stage: 'store', runId, message: errorMessage(error) })
323
+ logger?.warn('journal sweep: run lookup failed; keeping the journal', {
324
+ runId,
325
+ error,
326
+ })
327
+ kept.push({ runId, names: runNames, reason: 'store-error' })
328
+ continue
329
+ }
330
+
331
+ if (record === null) {
332
+ // Unknown to the store: either the record has not been written yet (the
333
+ // normal case for a run that just started) or it was deleted after the
334
+ // run ended. Only age distinguishes them.
335
+ if (ageGate === 'unavailable') {
336
+ kept.push({ runId, names: runNames, reason: 'age-gate-unavailable' })
337
+ continue
338
+ }
339
+ const observed = runNames.map((name) => mtimes.get(name))
340
+ if (observed.some((mtimeMs) => mtimeMs === undefined)) {
341
+ kept.push({ runId, names: runNames, reason: 'age-gate-missing-entry' })
342
+ continue
343
+ }
344
+ // The NEWEST of the run's files decides: a journal whose sidecar was
345
+ // written a second ago is being written to, whatever the journal's own
346
+ // mtime says.
347
+ const newest = Math.max(...observed.filter(isDefined))
348
+ if (newest > orphanCutoff) {
349
+ kept.push({ runId, names: runNames, reason: 'orphan-too-recent' })
350
+ continue
351
+ }
352
+ } else if (!isTerminalRunStatus(record.status)) {
353
+ // `'interrupted'` lands here, and must: it is a human-in-the-loop PAUSE
354
+ // that interrupt-resume continues from, not an end state.
355
+ kept.push({ runId, names: runNames, reason: 'non-terminal' })
356
+ continue
357
+ }
358
+
359
+ // Shell `rm`, never `handle.fs.remove`: module doc, and `journalPaths`
360
+ // re-derives byte-identical paths from the runId alone.
361
+ const command = journalCleanupCommand(journalPaths(runId, dir))
362
+ try {
363
+ const result = await options.handle.process.exec(command)
364
+ if (result.exitCode !== 0) {
365
+ failures.push({
366
+ stage: 'delete',
367
+ runId,
368
+ message: `rm exited ${result.exitCode}`,
369
+ })
370
+ kept.push({ runId, names: runNames, reason: 'delete-failed' })
371
+ continue
372
+ }
373
+ } catch (error) {
374
+ // A failed cleanup must never fail the sweep: the journal is still there
375
+ // and the next sweep will see it again.
376
+ failures.push({ stage: 'delete', runId, message: errorMessage(error) })
377
+ logger?.warn('journal sweep: deleting a journal failed', { runId, error })
378
+ kept.push({ runId, names: runNames, reason: 'delete-failed' })
379
+ continue
380
+ }
381
+ deleted.push(runId)
382
+ }
383
+
384
+ logger?.sandbox('journal sweep complete', {
385
+ dir,
386
+ listed: names.length,
387
+ runIds: byRunId.size,
388
+ deleted: deleted.length,
389
+ kept: kept.length,
390
+ ageGate,
391
+ })
392
+
393
+ return {
394
+ listed: names.length,
395
+ runIds: byRunId.size,
396
+ deleted,
397
+ kept,
398
+ ageGate,
399
+ failures,
400
+ }
401
+ }
402
+
403
+ /** Narrowing predicate: `Array<number | undefined>` → `Array<number>`. */
404
+ function isDefined(value: number | undefined): value is number {
405
+ return value !== undefined
406
+ }