@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,847 @@
1
+ import { decodeJournalRunId, exitSentinelLine, journalCleanupCommand, journalExistsCommand, journalListCommand, journalMtimeListCommand, journalPaths, journalReadCommand, journalStderrReadCommand, journaledCommand, parseJournalMtimeListing } from "../journal.js";
2
+ import { chunkFingerprint, createRunScopedIdGen } from "../chunk-identity.js";
3
+ import { journalReadStrategy, readJournal } from "../journal-reader.js";
4
+ import { readJournalNdjson } from "../runner.js";
5
+ import { pruneJournals } from "../journal-sweep.js";
6
+ import { probeRunExit, reapDetachedRuns } from "../reap.js";
7
+ import { waitForJournal } from "./journal-conformance.js";
8
+ import { EventType, InMemoryRunStore } from "@tanstack/ai";
9
+ import { InMemoryLockStore } from "@tanstack/ai/locks";
10
+ import { randomUUID } from "node:crypto";
11
+ import { describe, expect, it } from "vitest";
12
+ //#region src/testkit/reaper-conformance.ts
13
+ /**
14
+ * Provider conformance for the two unattended sweeps: `pruneJournals`
15
+ * (`journal-sweep.ts`) and `reapDetachedRuns` (`reap.ts`), against a REAL
16
+ * sandbox.
17
+ *
18
+ * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Both sweeps are almost
19
+ * entirely *shell* — `ls -1`, `stat -c '%Y %n'`, `rm -f`, `tail -c -N | base64`
20
+ * — composed as strings by `journal.ts` and executed by a provider. The unit
21
+ * suites drive fakes: an `exec` that answers from a scripted table, a
22
+ * filesystem that is a `Map`. A fake cannot be wrong about `stat` the way a
23
+ * BusyBox actually is, and on this feature that gap has already produced four
24
+ * defects that every unit test passed (see `takeover-conformance.ts`'s module
25
+ * doc for the roster). So the four properties the sweeps rest on are asserted
26
+ * here through a real shell against real files:
27
+ *
28
+ * 1. **A deletion really deletes, and a keep really keeps.** Asserted with
29
+ * `test -f` through the provider's shell, NEVER `handle.fs.exists`: on
30
+ * local-process the two resolve `/tmp` differently, so an `fs` probe answers
31
+ * about a path the journal was never written to (`journal.ts` rule 3). A
32
+ * sweep that "succeeded" while deleting nothing passes an `fs` probe.
33
+ * 2. **The age gate's self-witness works on THIS shell.** `journalMtimeListCommand`
34
+ * passes the directory as `stat`'s own first operand precisely because
35
+ * BusyBox exits 1 with EMPTY stdout on an unrecognised flag, and an empty
36
+ * parse read as an empty directory would delete every live run's journal. The
37
+ * docker provider's image is `alpine:3` — BusyBox 1.37, where `find -newermt`
38
+ * and `find -printf` are unrecognised — so the docker matrix is the authority
39
+ * on this case, not the local-process one (on Windows local-process execs
40
+ * through git-bash, whose `find`/`stat` are GNU-flavoured).
41
+ * 3. **The reaper never drives a live run.** The `'producing'` case asserts
42
+ * ABSENCE — nothing appended, `close()` not called, not one `runs.update`,
43
+ * `detachedSince` intact — because that is the shape of the defect
44
+ * `probeRunExit` exists to prevent: entering `pipeToRunLog` to "check" writes
45
+ * a terminal status and drops the run out of `listReclaimable` forever.
46
+ * 4. **A shell-hostile runId cannot become a shell-hostile command.** The encode
47
+ * → journal → follow → `ls` → decode → `rm` round trip runs on a runId
48
+ * containing `/`, a space, `;`, `$( )` and an embedded `touch`, with a canary
49
+ * file asserted absent. An ENCODING bug here is arbitrary command execution
50
+ * inside the sandbox, not a cosmetic defect.
51
+ *
52
+ * **What the canary proves, exactly, and what it does not.** It detects a
53
+ * runId reaching the shell WITHOUT `encodeRunId` — that is the mutation it
54
+ * bites on, and it bites hard: `journaledCommand`, `journalFollowCommand`,
55
+ * `journalExitProbeCommand`, `journalStderrReadCommand` and
56
+ * `journalCleanupCommand` all interpolate the path, so the `;touch` executes
57
+ * and the canary appears. It is BLIND to the loss of `journal.ts`'s
58
+ * `shellQuote`, the second and independent layer. Measured: with `shellQuote`
59
+ * reduced to the identity while `encodeRunId` stays, the redirect target
60
+ * becomes `>> /tmp/…/rp-a_3btouch_20_2ftmp…ndjson` — a single shell word of
61
+ * `[A-Za-z0-9._/-]`, because the encoder already removed every character a
62
+ * shell can act on — so no canary fires and NOTHING in this suite, or in any
63
+ * other real-provider suite, changes. Do not read a green run here as licence
64
+ * to "simplify" `shellQuote` away.
65
+ *
66
+ * The quoting is pinned instead by exact-string unit tests in
67
+ * `packages/ai-sandbox/tests/journal.test.ts`, which compare each composed
68
+ * command to a literal containing the quotes. By name, one per command:
69
+ * `journaledCommand` — "redirects stdout to the journal, stderr to its own
70
+ * file, and appends the exit sentinel" plus "quotes an adversarial runId so it
71
+ * cannot inject shell metacharacters"; `journalFollowCommand` — "translates a
72
+ * 0-based consumed-byte count into tail -c +N (1-based)";
73
+ * `journalReadCommand` — "the bounded read drops -f and keeps the base64
74
+ * frame, so a poll cannot hang"; `journalExistsCommand` — "probes through the
75
+ * shell, never through fs.*"; `journalStderrReadCommand` — "reads a BOUNDED
76
+ * tail of the sidecar, base64-framed, stderr silenced";
77
+ * `journalCleanupCommand`, `journalMtimeListCommand` and
78
+ * `journalExitProbeCommand` — the first `it` under each of their `describe`s.
79
+ * Those are the tests that go red on a dropped `shellQuote`; keep them exact.
80
+ *
81
+ * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.
82
+ * As in the journal and takeover suites there is deliberately no silent-skip
83
+ * path: a conformance case that quietly returns prints as a pass, which is how
84
+ * an unimplemented capability ships green.
85
+ *
86
+ * EVERY WAIT IN THIS FILE IS BOUNDED, and every journal directory is unique per
87
+ * case — see {@link caseDir}. This suite DELETES FILES, and
88
+ * `DEFAULT_JOURNAL_DIR` is a fixed absolute path shared with every other test
89
+ * and, on local-process, with a developer's real runs.
90
+ *
91
+ * Vitest is an OPTIONAL peer dependency: this module is imported only from test
92
+ * files, which already run under Vitest.
93
+ */
94
+ /** Poll interval handed to providers that cannot follow a growing file. */
95
+ var POLL_INTERVAL_MS = 50;
96
+ /**
97
+ * Quiescence window for the reaper's first append. Short because the agent in
98
+ * these cases has provably stopped (the suite waited for its sentinel) — the
99
+ * gate still runs, it just does not need to wait 5s to observe nothing.
100
+ */
101
+ var FENCE_QUIET_MS = 25;
102
+ /**
103
+ * Bound on a real journal read, so a reader that delivers nothing FAILS instead
104
+ * of parking CI.
105
+ *
106
+ * Never an assertion, and deliberately far above anything a healthy read needs
107
+ * (measured: 10–18s for the follow cases on both providers). Every use site
108
+ * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails
109
+ * naming this backstop rather than as a downstream transcript mismatch — which
110
+ * means this number can be raised freely and must never be the thing a case is
111
+ * tuned against.
112
+ */
113
+ var READ_BACKSTOP_MS = 9e4;
114
+ /** Long enough that nothing in this suite is ever classified as expired. */
115
+ var NEVER_EXPIRES_MS = 3600 * 1e3;
116
+ /**
117
+ * A journal directory nothing else on the machine writes to, created fresh for
118
+ * EVERY case.
119
+ *
120
+ * Not `DEFAULT_JOURNAL_DIR`, and not even one directory per suite. Both sweeps
121
+ * under test enumerate a whole directory and then DELETE from it, so a shared
122
+ * directory would let one case's leftovers become another's input — and on
123
+ * local-process the sandbox shell shares the host's real `/tmp`, where
124
+ * `DEFAULT_JOURNAL_DIR` holds a developer's actual runs.
125
+ */
126
+ function caseDir() {
127
+ return `/tmp/tanstack-reaper-conformance-${randomUUID()}`;
128
+ }
129
+ /**
130
+ * Unique per run, and it must be: `journalPaths` derives the filename from the
131
+ * runId and the journal is append-only, so a reused id appends BEHIND the
132
+ * previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing
133
+ * at all (see `journal.ts`).
134
+ */
135
+ function uniqueRunId(label) {
136
+ return `rp-${label}-${randomUUID()}`;
137
+ }
138
+ /**
139
+ * Single-quote a shell word, POSIX-style — the same rule `journal.ts`'s private
140
+ * `shellQuote` applies.
141
+ *
142
+ * Duplicated rather than exported from production code on purpose: this exists
143
+ * only for this suite's `rm -rf` teardown, which is not a production operation
144
+ * and must not become one by growing an export for it.
145
+ */
146
+ function quote(value) {
147
+ return `'${value.replaceAll("'", `'\\''`)}'`;
148
+ }
149
+ /** Remove a case's journal directory and everything in it. Best effort. */
150
+ async function removeDir(handle, dir) {
151
+ try {
152
+ await handle.process.exec(`rm -rf ${quote(dir)}`);
153
+ } catch {}
154
+ }
155
+ /**
156
+ * Does `path` exist, according to the SANDBOX'S SHELL?
157
+ *
158
+ * `journalExistsCommand` rather than `handle.fs.exists`, for any path and not
159
+ * just a journal: `journal.ts` rule 3 — on local-process `fs.*` resolves `/tmp`
160
+ * under the sandbox root while a shell redirect hits the host's real `/tmp`, so
161
+ * an `fs` probe would answer about a different file and every deletion
162
+ * assertion in this suite would pass vacuously.
163
+ */
164
+ async function fileExists(handle, path) {
165
+ return (await handle.process.exec(journalExistsCommand({ journal: path }))).exitCode === 0;
166
+ }
167
+ /** Filename as `ls -1` reports it, for a path inside `dir`. */
168
+ function basename(dir, path) {
169
+ return path.slice(dir.length + 1);
170
+ }
171
+ /**
172
+ * A real agent: a shell command printing one NDJSON line per delta, then
173
+ * exiting.
174
+ *
175
+ * `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils
176
+ * and on BusyBox alike, so this needs no loop. The JSON contains only double
177
+ * quotes, so it is safe inside the POSIX single-quoted words this builds.
178
+ */
179
+ function emitLines(deltas) {
180
+ return `printf '%s\\n' ${deltas.map((delta) => `'{"delta":"${delta}"}'`).join(" ")}`;
181
+ }
182
+ /**
183
+ * Run a journaled agent to completion, so the `{"__exit":N}` sentinel is in the
184
+ * journal by the time this resolves.
185
+ *
186
+ * `exec`, not `spawn`: `exec` waits, and a bounded wait is the only kind this
187
+ * suite allows. (`SpawnHandle.wait()` is also not safe to call after the fact on
188
+ * every provider — see `journal-conformance.ts`.)
189
+ */
190
+ async function runAgent(handle, paths, deltas) {
191
+ await handle.process.exec(journaledCommand(emitLines(deltas), paths));
192
+ }
193
+ /** `ls -1` output as a list of names. */
194
+ async function listNames(handle, dir) {
195
+ return (await handle.process.exec(journalListCommand(dir))).stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
196
+ }
197
+ /** Decode the base64 frame a bounded journal read produces. */
198
+ function decodeJournalRead(stdout) {
199
+ return Buffer.from(stdout.replace(/\s+/g, ""), "base64").toString("utf8");
200
+ }
201
+ /**
202
+ * The `stat -c '%Y %n'` listing for `dir`, plus the raw stdout so a case can
203
+ * assert the WITNESS LINE itself rather than only its parsed consequence.
204
+ */
205
+ async function mtimeListing(handle, dir) {
206
+ const probe = await handle.process.exec(journalMtimeListCommand(dir));
207
+ const parsed = parseJournalMtimeListing(probe.stdout, dir);
208
+ if (parsed.kind !== "listed") throw new Error(`reaper conformance: the mtime listing for ${dir} came back unavailable — stat -c '%Y %n' produced no witness line. stdout: ${JSON.stringify(probe.stdout)}`);
209
+ return {
210
+ stdout: probe.stdout,
211
+ entries: new Map(parsed.entries.map((entry) => [entry.name, entry.mtimeMs]))
212
+ };
213
+ }
214
+ /**
215
+ * Is there a `<seconds> <dir>` line — `stat`'s report on its own first operand?
216
+ *
217
+ * That line, not the exit status, is the evidence the mechanism ran: BusyBox
218
+ * exits 1 both for an EMPTY directory (whose unexpanded glob it cannot stat) and
219
+ * for an unrecognised flag, and only the witness distinguishes them.
220
+ */
221
+ function hasWitnessLine(stdout, dir) {
222
+ return stdout.split("\n").some((line) => /^\d+ (?<path>.+)$/.exec(line.trim())?.[1] === dir);
223
+ }
224
+ /** Read one file's mtime out of a listing, loudly when it is missing. */
225
+ function mtimeOf(entries, name) {
226
+ const mtimeMs = entries.get(name);
227
+ if (mtimeMs === void 0) throw new Error(`reaper conformance: ${name} has no mtime in the stat listing, so the age gate cannot be exercised`);
228
+ return mtimeMs;
229
+ }
230
+ function conformanceLog() {
231
+ const entries = [];
232
+ let closes = 0;
233
+ return {
234
+ log: {
235
+ resumeFrom: () => null,
236
+ append: (chunks) => Promise.resolve(chunks.map((chunk) => {
237
+ const offset = `reap:${entries.length}`;
238
+ entries.push({
239
+ offset,
240
+ chunk
241
+ });
242
+ return offset;
243
+ })),
244
+ read: () => void 0,
245
+ close: () => {
246
+ closes += 1;
247
+ return Promise.resolve();
248
+ },
249
+ snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry })))
250
+ },
251
+ stored: () => entries.map((entry) => entry.chunk),
252
+ closes: () => closes
253
+ };
254
+ }
255
+ function countingRunStore(inner) {
256
+ let updates = 0;
257
+ return {
258
+ runs: {
259
+ createOrResume: (...args) => inner.createOrResume(...args),
260
+ update: (...args) => {
261
+ updates += 1;
262
+ return inner.update(...args);
263
+ },
264
+ get: (...args) => inner.get(...args),
265
+ listByThread: (...args) => inner.listByThread(...args),
266
+ listReclaimable: (...args) => inner.listReclaimable(...args),
267
+ findActiveRun: (...args) => inner.findActiveRun(...args)
268
+ },
269
+ updates: () => updates
270
+ };
271
+ }
272
+ /** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */
273
+ function contentChunk(messageId, delta) {
274
+ return {
275
+ type: EventType.TEXT_MESSAGE_CONTENT,
276
+ messageId,
277
+ delta,
278
+ timestamp: Date.now()
279
+ };
280
+ }
281
+ /**
282
+ * Narrow one parsed journal line into its chunk.
283
+ *
284
+ * Fields are validated and the chunk REBUILT from them rather than asserted into
285
+ * shape: a cast would let a provider that mangles the bytes reach
286
+ * `chunkFingerprint` as a structurally invalid chunk and fail somewhere
287
+ * unrelated.
288
+ */
289
+ function toChunk(runId, messageId, value) {
290
+ if (typeof value !== "object" || value === null || !("delta" in value)) throw new Error(`reaper conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`);
291
+ const delta = value.delta;
292
+ if (typeof delta !== "string") throw new Error(`reaper conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`);
293
+ return contentChunk(messageId, delta);
294
+ }
295
+ /** Deterministic translator: re-reading the journal reproduces the same chunks. */
296
+ async function* translate(runId, lines) {
297
+ const messageId = createRunScopedIdGen(runId)();
298
+ for await (const line of lines) yield toChunk(runId, messageId, line);
299
+ }
300
+ /** A comparable transcript: each chunk reduced to its fingerprint. */
301
+ function transcript(chunks) {
302
+ return chunks.map(chunkFingerprint);
303
+ }
304
+ /** The chunks a run over `deltas` must deliver, exactly once and in order. */
305
+ function expectedTranscript(runId, deltas) {
306
+ const messageId = createRunScopedIdGen(runId)();
307
+ return deltas.map((delta) => contentChunk(messageId, delta));
308
+ }
309
+ /**
310
+ * The reaper's `drive`: read the run's journal from byte 0 and translate it.
311
+ *
312
+ * The read is bounded independently of `signal` so a journal that stops growing
313
+ * fails the case instead of hanging CI.
314
+ *
315
+ * Returns the drive alongside `backstopped()`, the causal witness for
316
+ * {@link READ_BACKSTOP_MS}: the case must assert it is `false` before its
317
+ * transcript assertions, so a read the CLOCK ended fails naming the backstop
318
+ * instead of as a truncated-transcript diff.
319
+ */
320
+ function driveFromJournal(handle, dir) {
321
+ const backstops = [];
322
+ return {
323
+ drive: ({ runId, signal }) => {
324
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
325
+ backstops.push(backstop);
326
+ return translate(runId, readJournalNdjson(handle, {
327
+ signal: AbortSignal.any([signal, backstop]),
328
+ journal: {
329
+ runId,
330
+ dir,
331
+ pollIntervalMs: POLL_INTERVAL_MS
332
+ }
333
+ }));
334
+ },
335
+ backstopped: () => backstops.some((s) => s.aborted)
336
+ };
337
+ }
338
+ /** A `'running'`, DETACHED record — the shape `listReclaimable` selects on. */
339
+ async function detachedRun(store, runId, threadId, detachedSince) {
340
+ await store.createOrResume({
341
+ runId,
342
+ threadId,
343
+ startedAt: Date.now()
344
+ });
345
+ await store.update(runId, { detachedSince });
346
+ }
347
+ function sleep(ms) {
348
+ return new Promise((resolve) => setTimeout(resolve, ms));
349
+ }
350
+ /**
351
+ * Assert `createHandle` satisfies the sweep conformance contract. Each `it` gets
352
+ * a fresh sandbox via `createHandle`/`dispose`, a fresh journal directory, and
353
+ * unique runIds, so no case can observe another's files.
354
+ */
355
+ function runReaperConformance(config) {
356
+ describe(`reaper conformance — ${config.name}`, () => {
357
+ if (config.unsupported) {
358
+ it.skip(`unsupported: ${config.unsupported.reason}`, () => {
359
+ expect(true).toBe(true);
360
+ });
361
+ return;
362
+ }
363
+ it("deletes a terminal run's journal AND its .err sidecar, while a running run's journal survives the same sweep", { timeout: 6e4 }, async () => {
364
+ const { handle, dispose } = await config.createHandle();
365
+ const dir = caseDir();
366
+ const terminalId = uniqueRunId("terminal");
367
+ const liveId = uniqueRunId("live");
368
+ const terminal = journalPaths(terminalId, dir);
369
+ const live = journalPaths(liveId, dir);
370
+ try {
371
+ await runAgent(handle, terminal, ["1"]);
372
+ await runAgent(handle, live, ["1"]);
373
+ expect({
374
+ terminalJournal: await fileExists(handle, terminal.journal),
375
+ terminalSidecar: await fileExists(handle, terminal.stderr),
376
+ liveJournal: await fileExists(handle, live.journal)
377
+ }).toEqual({
378
+ terminalJournal: true,
379
+ terminalSidecar: true,
380
+ liveJournal: true
381
+ });
382
+ const runs = new InMemoryRunStore();
383
+ await runs.createOrResume({
384
+ runId: terminalId,
385
+ threadId: `${terminalId}-t`,
386
+ startedAt: Date.now()
387
+ });
388
+ await runs.update(terminalId, {
389
+ status: "completed",
390
+ finishedAt: Date.now()
391
+ });
392
+ await runs.createOrResume({
393
+ runId: liveId,
394
+ threadId: `${liveId}-t`,
395
+ startedAt: Date.now()
396
+ });
397
+ const result = await pruneJournals({
398
+ handle,
399
+ runs,
400
+ dir
401
+ });
402
+ expect(result.deleted).toEqual([terminalId]);
403
+ expect(result.failures).toEqual([]);
404
+ expect(result.kept).toEqual([{
405
+ runId: liveId,
406
+ names: expect.any(Array),
407
+ reason: "non-terminal"
408
+ }]);
409
+ expect({
410
+ terminalJournal: await fileExists(handle, terminal.journal),
411
+ terminalSidecar: await fileExists(handle, terminal.stderr),
412
+ liveJournal: await fileExists(handle, live.journal),
413
+ liveSidecar: await fileExists(handle, live.stderr)
414
+ }).toEqual({
415
+ terminalJournal: false,
416
+ terminalSidecar: false,
417
+ liveJournal: true,
418
+ liveSidecar: true
419
+ });
420
+ } finally {
421
+ await removeDir(handle, dir);
422
+ await dispose();
423
+ }
424
+ });
425
+ it("sweeps the same terminal run twice without a failure, and rm -f of an already-absent journal exits 0", { timeout: 6e4 }, async () => {
426
+ const { handle, dispose } = await config.createHandle();
427
+ const dir = caseDir();
428
+ const runId = uniqueRunId("twice");
429
+ const paths = journalPaths(runId, dir);
430
+ try {
431
+ await runAgent(handle, paths, ["1"]);
432
+ const runs = new InMemoryRunStore();
433
+ await runs.createOrResume({
434
+ runId,
435
+ threadId: `${runId}-t`,
436
+ startedAt: Date.now()
437
+ });
438
+ await runs.update(runId, {
439
+ status: "completed",
440
+ finishedAt: Date.now()
441
+ });
442
+ expect((await pruneJournals({
443
+ handle,
444
+ runs,
445
+ dir
446
+ })).deleted).toEqual([runId]);
447
+ const second = await pruneJournals({
448
+ handle,
449
+ runs,
450
+ dir
451
+ });
452
+ expect({
453
+ listed: second.listed,
454
+ runIds: second.runIds,
455
+ deleted: second.deleted,
456
+ kept: second.kept,
457
+ failures: second.failures
458
+ }).toEqual({
459
+ listed: 0,
460
+ runIds: 0,
461
+ deleted: [],
462
+ kept: [],
463
+ failures: []
464
+ });
465
+ expect((await handle.process.exec(journalCleanupCommand(paths))).exitCode).toBe(0);
466
+ } finally {
467
+ await removeDir(handle, dir);
468
+ await dispose();
469
+ }
470
+ });
471
+ it("leaves a filename it cannot decode alone, while still sweeping the terminal run beside it", { timeout: 6e4 }, async () => {
472
+ const { handle, dispose } = await config.createHandle();
473
+ const dir = caseDir();
474
+ const runId = uniqueRunId("undecodable");
475
+ const paths = journalPaths(runId, dir);
476
+ const strayName = "reaper-conformance-stray_1.ndjson";
477
+ const strayPath = `${dir}/${strayName}`;
478
+ try {
479
+ await runAgent(handle, paths, ["1"]);
480
+ await handle.process.exec(`printf 'not a journal\\n' >> ${quote(strayPath)}`);
481
+ expect(await fileExists(handle, strayPath)).toBe(true);
482
+ expect(decodeJournalRunId(strayName).kind).toBe("malformed");
483
+ const runs = new InMemoryRunStore();
484
+ await runs.createOrResume({
485
+ runId,
486
+ threadId: `${runId}-t`,
487
+ startedAt: Date.now()
488
+ });
489
+ await runs.update(runId, {
490
+ status: "completed",
491
+ finishedAt: Date.now()
492
+ });
493
+ const result = await pruneJournals({
494
+ handle,
495
+ runs,
496
+ dir
497
+ });
498
+ expect(result.deleted).toEqual([runId]);
499
+ expect(result.kept).toEqual([{
500
+ names: [strayName],
501
+ reason: "undecodable-name"
502
+ }]);
503
+ expect(result.failures).toEqual([]);
504
+ expect({
505
+ strayKept: await fileExists(handle, strayPath),
506
+ journalDeleted: !await fileExists(handle, paths.journal)
507
+ }).toEqual({
508
+ strayKept: true,
509
+ journalDeleted: true
510
+ });
511
+ } finally {
512
+ await removeDir(handle, dir);
513
+ await dispose();
514
+ }
515
+ });
516
+ it("emits stat's self-witness line for a populated directory, so the age gate is usable", { timeout: 6e4 }, async () => {
517
+ const { handle, dispose } = await config.createHandle();
518
+ const dir = caseDir();
519
+ const paths = journalPaths(uniqueRunId("witness"), dir);
520
+ try {
521
+ await runAgent(handle, paths, ["1"]);
522
+ const listing = await mtimeListing(handle, dir);
523
+ expect(hasWitnessLine(listing.stdout, dir)).toBe(true);
524
+ expect([...listing.entries.keys()].sort()).toEqual([basename(dir, paths.journal), basename(dir, paths.stderr)].sort());
525
+ for (const mtimeMs of listing.entries.values()) expect(Math.abs(Date.now() - mtimeMs)).toBeLessThan(12e4);
526
+ } finally {
527
+ await removeDir(handle, dir);
528
+ await dispose();
529
+ }
530
+ });
531
+ it("reports an EMPTY journal directory as witness-only rather than unavailable", { timeout: 6e4 }, async () => {
532
+ const { handle, dispose } = await config.createHandle();
533
+ const dir = caseDir();
534
+ try {
535
+ await handle.process.exec(`mkdir -p ${quote(dir)}`);
536
+ const listing = await mtimeListing(handle, dir);
537
+ expect(hasWitnessLine(listing.stdout, dir)).toBe(true);
538
+ expect([...listing.entries.keys()]).toEqual([]);
539
+ const result = await pruneJournals({
540
+ handle,
541
+ runs: new InMemoryRunStore(),
542
+ dir
543
+ });
544
+ expect({
545
+ listed: result.listed,
546
+ ageGate: result.ageGate,
547
+ deleted: result.deleted,
548
+ failures: result.failures
549
+ }).toEqual({
550
+ listed: 0,
551
+ ageGate: "listed",
552
+ deleted: [],
553
+ failures: []
554
+ });
555
+ } finally {
556
+ await removeDir(handle, dir);
557
+ await dispose();
558
+ }
559
+ });
560
+ it("keeps an orphan younger than orphanTtlMs and sweeps the older one, in the same pass", { timeout: 12e4 }, async () => {
561
+ const { handle, dispose } = await config.createHandle();
562
+ const dir = caseDir();
563
+ const olderId = uniqueRunId("older");
564
+ const newerId = uniqueRunId("newer");
565
+ const older = journalPaths(olderId, dir);
566
+ const newer = journalPaths(newerId, dir);
567
+ try {
568
+ await runAgent(handle, older, ["1"]);
569
+ await sleep(2500);
570
+ await runAgent(handle, newer, ["1"]);
571
+ const listing = await mtimeListing(handle, dir);
572
+ const newestOf = (paths) => Math.max(mtimeOf(listing.entries, basename(dir, paths.journal)), mtimeOf(listing.entries, basename(dir, paths.stderr)));
573
+ const olderMtime = newestOf(older);
574
+ const newerMtime = newestOf(newer);
575
+ expect(newerMtime - olderMtime).toBeGreaterThanOrEqual(1e3);
576
+ const now = Date.now();
577
+ const cutoff = olderMtime + Math.floor((newerMtime - olderMtime) / 2);
578
+ const result = await pruneJournals({
579
+ handle,
580
+ runs: new InMemoryRunStore(),
581
+ dir,
582
+ now,
583
+ orphanTtlMs: now - cutoff
584
+ });
585
+ expect(result.ageGate).toBe("listed");
586
+ expect(result.deleted).toEqual([olderId]);
587
+ expect(result.kept).toEqual([{
588
+ runId: newerId,
589
+ names: expect.any(Array),
590
+ reason: "orphan-too-recent"
591
+ }]);
592
+ expect(result.failures).toEqual([]);
593
+ expect({
594
+ olderJournal: await fileExists(handle, older.journal),
595
+ olderSidecar: await fileExists(handle, older.stderr),
596
+ newerJournal: await fileExists(handle, newer.journal),
597
+ newerSidecar: await fileExists(handle, newer.stderr)
598
+ }).toEqual({
599
+ olderJournal: false,
600
+ olderSidecar: false,
601
+ newerJournal: true,
602
+ newerSidecar: true
603
+ });
604
+ } finally {
605
+ await removeDir(handle, dir);
606
+ await dispose();
607
+ }
608
+ });
609
+ it("finalizes a detached run whose agent reached its sentinel, and its transcript lands", { timeout: 12e4 }, async () => {
610
+ const { handle, dispose } = await config.createHandle();
611
+ const dir = caseDir();
612
+ const runId = uniqueRunId("finalize");
613
+ const threadId = `${runId}-t`;
614
+ const deltas = [
615
+ "1",
616
+ "2",
617
+ "3"
618
+ ];
619
+ const paths = journalPaths(runId, dir);
620
+ try {
621
+ const runs = new InMemoryRunStore();
622
+ const detachedSince = Date.now();
623
+ await detachedRun(runs, runId, threadId, detachedSince);
624
+ await runAgent(handle, paths, deltas);
625
+ expect(await probeRunExit({
626
+ handle,
627
+ runId,
628
+ dir
629
+ })).toEqual({
630
+ state: "finished",
631
+ exitCode: 0
632
+ });
633
+ const log = conformanceLog();
634
+ const journalDrive = driveFromJournal(handle, dir);
635
+ const result = await reapDetachedRuns({
636
+ runs,
637
+ locks: new InMemoryLockStore(),
638
+ durability: () => log.log,
639
+ hasFinished: (record) => probeRunExit({
640
+ handle,
641
+ runId: record.runId,
642
+ dir
643
+ }),
644
+ drive: journalDrive.drive,
645
+ now: Date.now(),
646
+ detachedRunTtlMs: NEVER_EXPIRES_MS,
647
+ fenceQuietMs: FENCE_QUIET_MS
648
+ });
649
+ expect({ backstopped: journalDrive.backstopped() }).toEqual({ backstopped: false });
650
+ expect({
651
+ considered: result.considered,
652
+ probed: result.probed,
653
+ finalized: result.outcomes.finalized
654
+ }).toEqual({
655
+ considered: 1,
656
+ probed: 1,
657
+ finalized: 1
658
+ });
659
+ expect(result.runs).toEqual([{
660
+ runId,
661
+ outcome: "finalized",
662
+ status: "completed",
663
+ exitCode: 0
664
+ }]);
665
+ expect(transcript(log.stored())).toEqual(transcript(expectedTranscript(runId, deltas)));
666
+ const record = await runs.get(runId);
667
+ expect(record?.status).toBe("completed");
668
+ expect(record?.detachedSince).toBe(detachedSince);
669
+ } finally {
670
+ await removeDir(handle, dir);
671
+ await dispose();
672
+ }
673
+ });
674
+ it("reports a still-producing detached run as producing and leaves it completely untouched", { timeout: 12e4 }, async () => {
675
+ const { handle, dispose } = await config.createHandle();
676
+ const dir = caseDir();
677
+ const runId = uniqueRunId("producing");
678
+ const threadId = `${runId}-t`;
679
+ const paths = journalPaths(runId, dir);
680
+ const store = countingRunStore(new InMemoryRunStore());
681
+ const agent = await handle.process.spawn(journaledCommand(`${emitLines(["1"])}; sleep 30`, paths));
682
+ try {
683
+ const detachedSince = Date.now();
684
+ await detachedRun(store.runs, runId, threadId, detachedSince);
685
+ await waitForJournal(handle, paths);
686
+ const text = decodeJournalRead((await handle.process.exec(journalReadCommand(paths, 0))).stdout);
687
+ expect(text).toContain("{\"delta\":\"1\"}");
688
+ expect(text).not.toContain("__exit");
689
+ expect(await probeRunExit({
690
+ handle,
691
+ runId,
692
+ dir
693
+ })).toEqual({ state: "producing" });
694
+ const log = conformanceLog();
695
+ let driveCalled = false;
696
+ let durabilityCalls = 0;
697
+ const result = await reapDetachedRuns({
698
+ runs: store.runs,
699
+ locks: new InMemoryLockStore(),
700
+ durability: () => {
701
+ durabilityCalls += 1;
702
+ return log.log;
703
+ },
704
+ hasFinished: (record) => probeRunExit({
705
+ handle,
706
+ runId: record.runId,
707
+ dir
708
+ }),
709
+ drive: () => {
710
+ driveCalled = true;
711
+ return (async function* never() {})();
712
+ },
713
+ now: Date.now(),
714
+ detachedRunTtlMs: NEVER_EXPIRES_MS,
715
+ fenceQuietMs: FENCE_QUIET_MS
716
+ });
717
+ expect(result.runs).toEqual([{
718
+ runId,
719
+ outcome: "producing"
720
+ }]);
721
+ expect({
722
+ considered: result.considered,
723
+ probed: result.probed,
724
+ producing: result.outcomes.producing,
725
+ finalized: result.outcomes.finalized,
726
+ expired: result.outcomes.expired,
727
+ failed: result.outcomes.failed
728
+ }).toEqual({
729
+ considered: 1,
730
+ probed: 1,
731
+ producing: 1,
732
+ finalized: 0,
733
+ expired: 0,
734
+ failed: 0
735
+ });
736
+ const record = await store.runs.get(runId);
737
+ expect({
738
+ driveCalled,
739
+ durabilityCalls,
740
+ appended: log.stored().length,
741
+ closes: log.closes(),
742
+ updatesAfterSetup: store.updates() - 1,
743
+ status: record?.status,
744
+ detachedSince: record?.detachedSince,
745
+ driverEpoch: record?.driverEpoch
746
+ }).toEqual({
747
+ driveCalled: false,
748
+ durabilityCalls: 0,
749
+ appended: 0,
750
+ closes: 0,
751
+ updatesAfterSetup: 0,
752
+ status: "running",
753
+ detachedSince,
754
+ driverEpoch: void 0
755
+ });
756
+ } finally {
757
+ try {
758
+ await agent.kill();
759
+ } catch {}
760
+ await removeDir(handle, dir);
761
+ await dispose();
762
+ }
763
+ });
764
+ it("round-trips a shell-hostile runId through encode, journal, follow, sidecar read, list, decode and delete without executing any of it" + (config.followUnsupported === void 0 ? "" : ` (follow read omitted: ${config.followUnsupported.reason})`), { timeout: 12e4 }, async () => {
765
+ const { handle, dispose } = await config.createHandle();
766
+ const dir = caseDir();
767
+ const nonce = randomUUID().slice(0, 8);
768
+ const canary = `/tmp/rp-pwn-${nonce}`;
769
+ const runId = `rp-a;touch ${canary};b c/d$(x)-${nonce}`;
770
+ const threadId = `${runId}-t`;
771
+ const paths = journalPaths(runId, dir);
772
+ const journalName = basename(dir, paths.journal);
773
+ try {
774
+ await handle.process.exec(journaledCommand(`${emitLines(["1"])}; printf 'boom\\n' 1>&2`, paths));
775
+ expect(await fileExists(handle, canary)).toBe(false);
776
+ expect(await probeRunExit({
777
+ handle,
778
+ runId,
779
+ dir
780
+ })).toEqual({
781
+ state: "finished",
782
+ exitCode: 0
783
+ });
784
+ expect(journalName).toMatch(/^[A-Za-z0-9._-]+\.ndjson$/);
785
+ expect(paths.journal.startsWith(`${dir}/`)).toBe(true);
786
+ expect(journalReadStrategy(handle)).toBe(config.followUnsupported === void 0 ? "follow" : "poll");
787
+ if (config.followUnsupported === void 0) {
788
+ const followed = [];
789
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
790
+ for await (const line of readJournal(handle, {
791
+ paths,
792
+ fromByte: 0,
793
+ strategy: "follow",
794
+ signal: backstop
795
+ })) {
796
+ followed.push(line.line);
797
+ if (line.line.includes("__exit")) break;
798
+ }
799
+ expect({ backstopped: backstop.aborted }).toEqual({ backstopped: false });
800
+ expect(followed).toEqual(["{\"delta\":\"1\"}", exitSentinelLine(paths, 0)]);
801
+ expect(await fileExists(handle, canary)).toBe(false);
802
+ }
803
+ expect(decodeJournalRead((await handle.process.exec(journalStderrReadCommand(paths))).stdout)).toBe("boom\n");
804
+ expect(await fileExists(handle, canary)).toBe(false);
805
+ expect((await listNames(handle, dir)).sort()).toEqual([journalName, basename(dir, paths.stderr)].sort());
806
+ expect(decodeJournalRunId(journalName)).toEqual({
807
+ kind: "runId",
808
+ runId
809
+ });
810
+ const runs = new InMemoryRunStore();
811
+ await runs.createOrResume({
812
+ runId,
813
+ threadId,
814
+ startedAt: Date.now()
815
+ });
816
+ await runs.update(runId, {
817
+ status: "completed",
818
+ finishedAt: Date.now()
819
+ });
820
+ const result = await pruneJournals({
821
+ handle,
822
+ runs,
823
+ dir
824
+ });
825
+ expect(result.deleted).toEqual([runId]);
826
+ expect(result.failures).toEqual([]);
827
+ expect({
828
+ journalDeleted: !await fileExists(handle, paths.journal),
829
+ sidecarDeleted: !await fileExists(handle, paths.stderr),
830
+ canaryAbsent: !await fileExists(handle, canary)
831
+ }).toEqual({
832
+ journalDeleted: true,
833
+ sidecarDeleted: true,
834
+ canaryAbsent: true
835
+ });
836
+ } finally {
837
+ await handle.process.exec(`rm -f ${quote(canary)}`).catch(() => void 0);
838
+ await removeDir(handle, dir);
839
+ await dispose();
840
+ }
841
+ });
842
+ });
843
+ }
844
+ //#endregion
845
+ export { runReaperConformance };
846
+
847
+ //# sourceMappingURL=reaper-conformance.js.map