@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.
- package/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- 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
|
+
}
|