@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,685 @@
1
+ import { journalCleanupCommand, journalExistsCommand, journalPaths, journaledCommand } from "../journal.js";
2
+ import { chunkFingerprint, createRunScopedIdGen } from "../chunk-identity.js";
3
+ import { alignedIfAttaching, journalOptionsFor, resolveSandboxDurability } from "../durability.js";
4
+ import { JournalAttachUnavailableError, awaitAttachableJournal } from "../attach-preflight.js";
5
+ import { readJournalNdjson, startJournaledAgent } from "../runner.js";
6
+ import { fenceDurability, withRunClaim } from "../claim.js";
7
+ import { sandboxRunDriver } from "../driver.js";
8
+ import { EventType, InMemoryRunStore } from "@tanstack/ai";
9
+ import { InMemoryLockStore } from "@tanstack/ai/locks";
10
+ import { describe, expect, it } from "vitest";
11
+ //#region src/testkit/takeover-conformance.ts
12
+ /**
13
+ * Provider conformance for TAKEOVER: a second driver picking up a run whose
14
+ * first driver died, against a REAL sandbox.
15
+ *
16
+ * WHY THIS EXISTS SEPARATELY FROM THE UNIT TESTS. Every takeover unit test in
17
+ * this package drives fakes — a scripted `spawn`, a `test -f` that answers from
18
+ * a boolean, a log that is an array. Fakes model what we believe the shell and
19
+ * the filesystem do, and on this feature that belief has been wrong three times:
20
+ * `base64` delivers zero bytes on a live pipe, `tail -f` on a missing file exits
21
+ * instead of waiting, and a provider's `kill` does not always reap a grandchild.
22
+ * Each one passed every fake. So the four properties a takeover actually rests
23
+ * on are asserted here through a provider's real `spawn`/`exec` against a real
24
+ * journal file:
25
+ *
26
+ * 1. **The delivered sequence is the run's sequence, with no duplicated
27
+ * prefix.** Asserted as a TRANSCRIPT, never as "chunks arrived": a takeover
28
+ * that replays the whole journal and re-appends everything satisfies the weak
29
+ * assertion while showing the user the entire run twice. That is the exact
30
+ * failure `alignToStoredLog` exists to prevent, and the only assertion that
31
+ * can see it is one that compares the stored log to the expected sequence
32
+ * element for element.
33
+ * 2. **The attach preflight decides, or fails, but never hangs.** It probes with
34
+ * the provider's real `exec` (`test -f`), which is the layer where a fake's
35
+ * assumptions break, and its three verdicts (`unknown-run`, `terminal-run`,
36
+ * `journal-timeout`) plus the legitimate late-journal race are all timing
37
+ * against a real filesystem.
38
+ * 3. **The epoch fence and its latch hold under real concurrency.** Two drivers
39
+ * reading one real journal at once: the second wins, the first appends
40
+ * NOTHING — not even `pipeToRunLog`'s recovery `RUN_ERROR` — and cannot
41
+ * terminalize the record out from under the live successor.
42
+ * 4. **A terminal run's journal is deleted, and a later attach says so.** The
43
+ * deletion is a real `rm` of real files, and the follow-up attach must report
44
+ * `terminal-run` rather than tailing the file that `journalFollowCommand`
45
+ * would helpfully re-create.
46
+ *
47
+ * WHAT IS REAL HERE. The provider (its `spawn`, `exec`, and shell), the journal
48
+ * (a real NDJSON file the agent's stdout is redirected into), the agent (a real
49
+ * process writing real lines with a real pause in the middle), the reader
50
+ * (`readJournalNdjson`, including the follow/poll strategy split and the attach
51
+ * preflight), the alignment (`alignedIfAttaching` over the real
52
+ * `resolveSandboxDurability` output), the claim and BOTH fences
53
+ * (`sandboxRunDriver`), and the run record (`InMemoryRunStore`). The event log is
54
+ * in-process, exactly as the recommended `memoryStream` backend is.
55
+ *
56
+ * A provider that cannot satisfy the contract MUST declare `unsupported.reason`.
57
+ * As in the journal suite there is deliberately no silent-skip path: a
58
+ * conformance case that quietly returns prints as a pass, which is how an
59
+ * unimplemented capability ships green.
60
+ *
61
+ * FOUND BY THIS SUITE, FIXED IN THE PROVIDER, STILL NOT ASSERTED HERE. On
62
+ * local-process under Windows (git-bash `sh`), the follow read's `tail`
63
+ * grandchild used to SURVIVE `proc.kill()`: `LocalProcessHandle.killTree` ran
64
+ * `taskkill /PID <sh> /T /F` and returned as soon as `spawnSync` reported no
65
+ * `error`. Two things were wrong. It never checked taskkill's exit status — and
66
+ * that alone would not have caught it, because MSYS's fork emulation leaves the
67
+ * `tail.exe` pointing at an intermediate shell that has already exited, so
68
+ * `taskkill /T` (live parent links only) cannot reach it and still exits `0`.
69
+ * Measured by counting `tail.exe` before and after a run: this suite leaked 4 per
70
+ * run and the shipped journal suite 2, accumulating for the life of the machine.
71
+ * It was a provider defect, not a takeover defect — every case here still
72
+ * delivered the right transcript, because `untilAborted` (see
73
+ * `journal-reader.ts`) stops honoring the pipe once the signal fires rather than
74
+ * waiting for the kill, which is exactly why it never failed a test.
75
+ * `killTree` now resolves the tree through MSYS's own process table and verifies
76
+ * the survivors are gone (0 per run), covered in
77
+ * `ai-sandbox-local-process/tests/kill-tree.test.ts`.
78
+ * Deliberately still NOT asserted in this suite: a per-provider process census is
79
+ * not portable (Docker's `tail` dies with its container), and a conformance case
80
+ * that counted host processes would fail for reasons unrelated to takeover.
81
+ *
82
+ * EVERY WAIT IN THIS FILE IS BOUNDED. A hang stalls CI instead of failing it, so
83
+ * each journal read carries a timeout signal, each poll loop carries a deadline
84
+ * and a message naming what never happened, and each case carries an explicit
85
+ * per-test timeout.
86
+ *
87
+ * Vitest is an OPTIONAL peer dependency: this module is imported only from test
88
+ * files, which already run under Vitest.
89
+ */
90
+ /**
91
+ * Journal directory for this suite, deliberately NOT
92
+ * {@link DEFAULT_JOURNAL_DIR}: on local-process the sandbox shell shares the
93
+ * host's real `/tmp`, so conformance runs must not write where an application's
94
+ * runs live.
95
+ */
96
+ var CONFORMANCE_JOURNAL_DIR = "/tmp/tanstack-takeover-conformance";
97
+ /** Poll interval handed to providers that cannot follow a growing file. */
98
+ var POLL_INTERVAL_MS = 50;
99
+ /**
100
+ * Quiescence window for the successor's first append. Short because the
101
+ * predecessor in these cases has provably stopped (the suite sequenced it) —
102
+ * the gate still runs, it just does not need to wait 5s to observe nothing.
103
+ */
104
+ var FENCE_QUIET_MS = 25;
105
+ /**
106
+ * Bound on a real journal read, so a reader that delivers nothing FAILS instead
107
+ * of parking CI.
108
+ *
109
+ * Never an assertion, and deliberately far above anything a healthy read needs
110
+ * (measured: 10–18s for the follow cases on both providers). Every use site
111
+ * pairs it with a `backstopped: false` witness, so a read the CLOCK ended fails
112
+ * naming this backstop rather than as a downstream transcript mismatch — which
113
+ * means this number can be raised freely and must never be the thing a case is
114
+ * tuned against.
115
+ */
116
+ var READ_BACKSTOP_MS = 9e4;
117
+ /**
118
+ * Unique per case, and it must be: `journalPaths` derives the file name from the
119
+ * `runId` and the journal is append-only, so a reused id appends BEHIND the
120
+ * previous run's `{"__exit":N}` sentinel and the new run appears to emit nothing
121
+ * at all (see `journal.ts`). The counter covers two cases created inside the
122
+ * same millisecond; the random suffix covers two suites sharing one `/tmp`.
123
+ */
124
+ var caseCounter = 0;
125
+ function uniqueRunId(label) {
126
+ caseCounter += 1;
127
+ const suffix = Math.random().toString(36).slice(2, 8);
128
+ return `tko-${label}-${Date.now()}-${caseCounter}-${suffix}`;
129
+ }
130
+ function conformanceLog() {
131
+ const entries = [];
132
+ let closes = 0;
133
+ return {
134
+ log: {
135
+ resumeFrom: () => null,
136
+ append: (chunks) => Promise.resolve(chunks.map((chunk) => {
137
+ const offset = `conf:${entries.length}`;
138
+ entries.push({
139
+ offset,
140
+ chunk
141
+ });
142
+ return offset;
143
+ })),
144
+ read: () => void 0,
145
+ close: () => {
146
+ closes += 1;
147
+ return Promise.resolve();
148
+ },
149
+ snapshot: () => Promise.resolve(entries.map((entry) => ({ ...entry })))
150
+ },
151
+ stored: () => entries.map((entry) => entry.chunk),
152
+ closes: () => closes
153
+ };
154
+ }
155
+ /**
156
+ * A lock that grants every request immediately and never reports a loss.
157
+ *
158
+ * `InMemoryLockStore` SERIALIZES claims within one process, so a second attach
159
+ * waits for the first to finish and the two drivers are never concurrent — which
160
+ * means the epoch fence can never be observed there. `claim.ts` says exactly
161
+ * that: in one process only layer 2, the `driverEpoch` fence, is provable. This
162
+ * models a lease-less lock so the two drives overlap and layer 2 does the work.
163
+ */
164
+ var permissiveLocks = { withLock: (_key, fn) => fn(new AbortController().signal) };
165
+ /** The event a journal line translates into. `timestamp` is excluded from `chunkFingerprint`. */
166
+ function contentChunk(messageId, delta) {
167
+ return {
168
+ type: EventType.TEXT_MESSAGE_CONTENT,
169
+ messageId,
170
+ delta,
171
+ timestamp: Date.now()
172
+ };
173
+ }
174
+ /**
175
+ * Narrow one parsed journal line into its chunk.
176
+ *
177
+ * Fields are validated and the chunk is REBUILT from them rather than asserted
178
+ * into shape: a cast would let a provider that mangles the bytes (a folded
179
+ * stderr diagnostic, a truncated line) reach `chunkFingerprint` as a
180
+ * structurally invalid chunk and fail somewhere unrelated.
181
+ */
182
+ function toChunk(runId, messageId, value) {
183
+ if (typeof value !== "object" || value === null || !("delta" in value)) throw new Error(`takeover conformance: run ${runId} journal line is not an agent event: ${JSON.stringify(value)}`);
184
+ const delta = value.delta;
185
+ if (typeof delta !== "string") throw new Error(`takeover conformance: run ${runId} journal line has a non-string delta: ${JSON.stringify(value)}`);
186
+ return contentChunk(messageId, delta);
187
+ }
188
+ /**
189
+ * The translator. Deterministic by construction, which is what makes alignment
190
+ * possible at all: the message id comes from {@link createRunScopedIdGen}, so
191
+ * re-translating the same journal from byte 0 reproduces byte-identical chunks
192
+ * (modulo `timestamp`, the one field `chunkFingerprint` excludes).
193
+ */
194
+ async function* translate(runId, lines) {
195
+ const messageId = createRunScopedIdGen(runId)();
196
+ for await (const line of lines) yield toChunk(runId, messageId, line);
197
+ }
198
+ /**
199
+ * A comparable transcript: each chunk reduced to its {@link chunkFingerprint}.
200
+ *
201
+ * The fingerprint, not the chunk object, and for the same reason alignment uses
202
+ * it — `timestamp` is wall-clock and unreproducible, so a raw `toEqual` on
203
+ * chunks would fail on the one field the feature deliberately ignores. Every
204
+ * other field participates, so a duplicated prefix, a dropped chunk, or a
205
+ * reordered one still fails.
206
+ */
207
+ function transcript(chunks) {
208
+ return chunks.map(chunkFingerprint);
209
+ }
210
+ /** The chunks a run over `deltas` must deliver, exactly once and in order. */
211
+ function expectedTranscript(runId, deltas) {
212
+ const messageId = createRunScopedIdGen(runId)();
213
+ return deltas.map((delta) => contentChunk(messageId, delta));
214
+ }
215
+ /**
216
+ * A real agent: a shell command that prints one NDJSON line per delta, with an
217
+ * optional real pause partway through, then exits.
218
+ *
219
+ * `printf '%s\n' a b c` reuses the format for every operand on GNU coreutils and
220
+ * on busybox alike, so this needs no loop. The JSON contains only double quotes,
221
+ * so it is safe inside the POSIX single-quoted words this builds.
222
+ */
223
+ function agentCommand(deltas, pauseAfter) {
224
+ const line = (delta) => `'{"delta":"${delta}"}'`;
225
+ const head = deltas.slice(0, pauseAfter);
226
+ const tail = deltas.slice(pauseAfter);
227
+ const parts = [`printf '%s\\n' ${head.map(line).join(" ")}`];
228
+ if (tail.length > 0) parts.push("sleep 2", `printf '%s\\n' ${tail.map(line).join(" ")}`);
229
+ return parts.join("; ");
230
+ }
231
+ /** Resolve durability through the production resolver, fresh or attaching. */
232
+ function durabilityFor(runs, log, attach) {
233
+ const resolved = resolveSandboxDurability({
234
+ runs,
235
+ durability: {
236
+ adapter: log,
237
+ journal: CONFORMANCE_JOURNAL_DIR,
238
+ attach,
239
+ pollIntervalMs: POLL_INTERVAL_MS
240
+ }
241
+ });
242
+ if (resolved === void 0) throw new Error("takeover conformance: resolveSandboxDurability returned undefined for a fully wired run");
243
+ return resolved;
244
+ }
245
+ /**
246
+ * The reader's journal options for a resolved durability.
247
+ *
248
+ * `journalOptionsFor` answers `undefined` for a NON-durable run, which cannot
249
+ * happen here — every run in this suite is fully wired. Narrowing it with a
250
+ * thrown error rather than a non-null assertion keeps the impossible case loud
251
+ * if the resolver's contract ever changes.
252
+ */
253
+ function journalOptions(durability, runId) {
254
+ const options = journalOptionsFor(durability, runId);
255
+ if (options === void 0) throw new Error(`takeover conformance: journalOptionsFor answered undefined for durable run ${runId}`);
256
+ return options;
257
+ }
258
+ /** A `'running'` record for `runId`, ready to be claimed. */
259
+ async function runningRun(runId, threadId) {
260
+ const runs = new InMemoryRunStore();
261
+ await runs.createOrResume({
262
+ runId,
263
+ threadId,
264
+ startedAt: Date.now()
265
+ });
266
+ return runs;
267
+ }
268
+ /**
269
+ * Wrap a handle so the `process.exec` calls ONE operation makes can be counted.
270
+ *
271
+ * This is how the attach preflight's fail-fast cases are anchored, and the reason
272
+ * they are not anchored on elapsed time. `awaitAttachableJournal` runs exactly one
273
+ * `test -f` before it consults the run store, so a decision made from the record
274
+ * costs one `exec` and a decision made by waiting costs one per
275
+ * `probeIntervalMs`. The count separates those two behaviors exactly; elapsed time
276
+ * does not, because a single `exec` is a provider round-trip whose latency the
277
+ * suite does not control — a `docker exec` on a loaded daemon has been measured at
278
+ * 9.6s, which fails a `< 4_000ms` bound while the preflight under test did
279
+ * precisely the right thing. A timing bound that goes red on a busy machine
280
+ * teaches people to ignore the suite.
281
+ *
282
+ * The spread copies the handle's own methods, so everything except `exec` is the
283
+ * provider's; the wrapper delegates rather than reimplementing.
284
+ */
285
+ function countingExec(handle) {
286
+ let execs = 0;
287
+ return {
288
+ handle: {
289
+ ...handle,
290
+ process: {
291
+ ...handle.process,
292
+ exec: (command, options) => {
293
+ execs += 1;
294
+ return handle.process.exec(command, options);
295
+ }
296
+ }
297
+ },
298
+ execs: () => execs
299
+ };
300
+ }
301
+ /** Poll `check` until it answers true, or fail with a message naming what never happened. */
302
+ async function waitUntil(check, options) {
303
+ const deadline = Date.now() + options.timeoutMs;
304
+ for (;;) {
305
+ if (await check()) return;
306
+ if (Date.now() > deadline) throw new Error(`takeover conformance: ${options.message} within ${options.timeoutMs}ms`);
307
+ await sleep(25);
308
+ }
309
+ }
310
+ function sleep(ms) {
311
+ return new Promise((resolve) => setTimeout(resolve, ms));
312
+ }
313
+ /** A one-shot gate, for sequencing two concurrent drivers deterministically. */
314
+ function gate() {
315
+ let open = () => {};
316
+ return {
317
+ promise: new Promise((resolve) => {
318
+ open = () => resolve();
319
+ }),
320
+ open
321
+ };
322
+ }
323
+ /**
324
+ * Build the driver a host would build for one run.
325
+ *
326
+ * `drive` is the real journal path: read the run's journal from byte 0 (through
327
+ * the attach preflight when attaching), translate, and align against the stored
328
+ * log — `alignedIfAttaching`, so alignment runs on an attach and only on an
329
+ * attach.
330
+ *
331
+ * Returns the driver alongside `backstopped()`, the causal witness for
332
+ * {@link READ_BACKSTOP_MS}: every case that drives this must assert it is
333
+ * `false` before its transcript assertions, so a read the CLOCK ended fails
334
+ * naming the backstop instead of as a truncated-transcript diff.
335
+ */
336
+ function driverFor(input) {
337
+ const durability = durabilityFor(input.runs, input.log, input.attach);
338
+ const backstops = [];
339
+ return {
340
+ driver: sandboxRunDriver({
341
+ request: new Request(`http://takeover.local/attach?runId=${encodeURIComponent(input.runId)}&offset=-1`),
342
+ runs: input.runs,
343
+ locks: input.locks,
344
+ durability: () => input.log,
345
+ fenceQuietMs: FENCE_QUIET_MS,
346
+ drive: ({ runId, signal }) => {
347
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
348
+ backstops.push(backstop);
349
+ const bounded = AbortSignal.any([signal, backstop]);
350
+ const lines = readJournalNdjson(input.handle, {
351
+ signal: bounded,
352
+ journal: journalOptions(durability, runId)
353
+ });
354
+ const gated = input.beforeFirstChunk;
355
+ return alignedIfAttaching(translate(runId, gated === void 0 ? lines : (async function* afterGate() {
356
+ let first = true;
357
+ for await (const value of lines) {
358
+ if (first) {
359
+ first = false;
360
+ await gated();
361
+ }
362
+ yield value;
363
+ }
364
+ })()), durability);
365
+ }
366
+ }),
367
+ backstopped: () => backstops.some((s) => s.aborted)
368
+ };
369
+ }
370
+ /** Exactly what core's `startRunDriver` does: claim, then pipe the drive. */
371
+ function takeOver(driver, input) {
372
+ const { runs, runId, threadId } = input;
373
+ return driver.claim({
374
+ runs,
375
+ locks: driver.locks,
376
+ runId
377
+ }, (claim) => driver.pipe(driver.drive({
378
+ runId,
379
+ threadId,
380
+ signal: claim.signal
381
+ }), {
382
+ runId,
383
+ threadId,
384
+ signal: claim.signal
385
+ }));
386
+ }
387
+ /** Best-effort removal of a case's journal files, through the shell (rule 3). */
388
+ async function cleanup(handle, runId) {
389
+ try {
390
+ await handle.process.exec(journalCleanupCommand(journalPaths(runId, CONFORMANCE_JOURNAL_DIR)));
391
+ } catch {}
392
+ }
393
+ /**
394
+ * Assert `createHandle` satisfies the takeover conformance contract. Each `it`
395
+ * gets a fresh sandbox via `createHandle`/`dispose`, and a unique `runId`, so no
396
+ * case can observe another's journal.
397
+ */
398
+ function runTakeoverConformance(config) {
399
+ describe(`takeover conformance — ${config.name}`, () => {
400
+ if (config.unsupported) {
401
+ it.skip(`unsupported: ${config.unsupported.reason}`, () => {
402
+ expect(true).toBe(true);
403
+ });
404
+ return;
405
+ }
406
+ it("delivers the run sequence exactly once when a second driver takes over mid-stream", { timeout: 18e4 }, async () => {
407
+ const { handle, dispose } = await config.createHandle();
408
+ const runId = uniqueRunId("e2e");
409
+ const threadId = `${runId}-t`;
410
+ const deltas = [
411
+ "1",
412
+ "2",
413
+ "3",
414
+ "4",
415
+ "5",
416
+ "6"
417
+ ];
418
+ const prefixLength = 3;
419
+ const expected = expectedTranscript(runId, deltas);
420
+ const runs = await runningRun(runId, threadId);
421
+ const log = conformanceLog();
422
+ try {
423
+ const fresh = durabilityFor(runs, log.log, false);
424
+ const deliveredByFirst = [];
425
+ const firstBackstop = AbortSignal.timeout(READ_BACKSTOP_MS);
426
+ await withRunClaim({
427
+ runs,
428
+ locks: new InMemoryLockStore(),
429
+ runId
430
+ }, async (claim) => {
431
+ const fenced = fenceDurability(log.log, claim, { runs });
432
+ await startJournaledAgent(handle, agentCommand(deltas, prefixLength), { journal: journalOptions(fresh, runId) });
433
+ const lines = readJournalNdjson(handle, {
434
+ signal: firstBackstop,
435
+ journal: journalOptions(fresh, runId)
436
+ });
437
+ for await (const chunk of translate(runId, lines)) {
438
+ await fenced.append([chunk]);
439
+ deliveredByFirst.push(chunk);
440
+ if (deliveredByFirst.length === prefixLength) break;
441
+ }
442
+ });
443
+ expect({ backstopped: firstBackstop.aborted }).toEqual({ backstopped: false });
444
+ expect(transcript(deliveredByFirst)).toEqual(transcript(expected.slice(0, prefixLength)));
445
+ const successor = driverFor({
446
+ handle,
447
+ runs,
448
+ locks: new InMemoryLockStore(),
449
+ log: log.log,
450
+ runId,
451
+ attach: true
452
+ });
453
+ const record = await takeOver(successor.driver, {
454
+ runs,
455
+ runId,
456
+ threadId
457
+ });
458
+ expect({ backstopped: successor.backstopped() }).toEqual({ backstopped: false });
459
+ expect(transcript(log.stored())).toEqual(transcript(expected));
460
+ expect(log.stored()).toHaveLength(deltas.length);
461
+ expect(transcript(log.stored().slice(prefixLength))).toEqual(transcript(expected.slice(prefixLength)));
462
+ const finalRecord = await runs.get(runId);
463
+ expect(finalRecord?.status).toBe("completed");
464
+ expect(finalRecord?.driverEpoch).toBe(2);
465
+ expect(record).not.toBeUndefined();
466
+ } finally {
467
+ await cleanup(handle, runId);
468
+ await dispose();
469
+ }
470
+ });
471
+ it("fails an attach to an unknown runId with unknown-run, without waiting it out", { timeout: 12e4 }, async () => {
472
+ const { handle, dispose } = await config.createHandle();
473
+ const runId = uniqueRunId("unknown");
474
+ try {
475
+ expect.hasAssertions();
476
+ const probes = countingExec(handle);
477
+ const error = await awaitAttachableJournal(probes.handle, {
478
+ paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),
479
+ runId,
480
+ runs: new InMemoryRunStore(),
481
+ waitMs: 8e3,
482
+ probeIntervalMs: POLL_INTERVAL_MS
483
+ }).then(() => null, (reason) => reason);
484
+ expect(error).toBeInstanceOf(JournalAttachUnavailableError);
485
+ if (!(error instanceof JournalAttachUnavailableError)) return;
486
+ expect(error.reason).toBe("unknown-run");
487
+ expect(probes.execs()).toBe(1);
488
+ } finally {
489
+ await dispose();
490
+ }
491
+ });
492
+ it("fails an attach to a terminal run whose journal is gone with terminal-run", { timeout: 12e4 }, async () => {
493
+ const { handle, dispose } = await config.createHandle();
494
+ const runId = uniqueRunId("terminal");
495
+ const threadId = `${runId}-t`;
496
+ try {
497
+ expect.hasAssertions();
498
+ const runs = await runningRun(runId, threadId);
499
+ await runs.update(runId, {
500
+ status: "completed",
501
+ finishedAt: 2
502
+ });
503
+ const probes = countingExec(handle);
504
+ const error = await awaitAttachableJournal(probes.handle, {
505
+ paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),
506
+ runId,
507
+ runs,
508
+ waitMs: 8e3,
509
+ probeIntervalMs: POLL_INTERVAL_MS
510
+ }).then(() => null, (reason) => reason);
511
+ expect(error).toBeInstanceOf(JournalAttachUnavailableError);
512
+ if (!(error instanceof JournalAttachUnavailableError)) return;
513
+ expect(error.reason).toBe("terminal-run");
514
+ expect(probes.execs()).toBe(1);
515
+ } finally {
516
+ await dispose();
517
+ }
518
+ });
519
+ it("waits for a live run whose journal appears late — the legitimate race", { timeout: 12e4 }, async () => {
520
+ const { handle, dispose } = await config.createHandle();
521
+ const runId = uniqueRunId("race");
522
+ const threadId = `${runId}-t`;
523
+ const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR);
524
+ try {
525
+ const runs = await runningRun(runId, threadId);
526
+ const writer = sleep(400).then(() => handle.process.exec(journaledCommand(`printf '{"delta":"1"}\\n'`, paths)));
527
+ try {
528
+ await awaitAttachableJournal(handle, {
529
+ paths,
530
+ runId,
531
+ runs,
532
+ waitMs: 2e4,
533
+ probeIntervalMs: POLL_INTERVAL_MS
534
+ });
535
+ } finally {
536
+ await writer;
537
+ }
538
+ expect((await handle.process.exec(journalExistsCommand(paths))).exitCode).toBe(0);
539
+ } finally {
540
+ await cleanup(handle, runId);
541
+ await dispose();
542
+ }
543
+ });
544
+ it("bounds the wait for a live run whose journal never appears, with journal-timeout", { timeout: 12e4 }, async () => {
545
+ const { handle, dispose } = await config.createHandle();
546
+ const runId = uniqueRunId("timeout");
547
+ const threadId = `${runId}-t`;
548
+ try {
549
+ expect.hasAssertions();
550
+ const runs = await runningRun(runId, threadId);
551
+ const error = await awaitAttachableJournal(handle, {
552
+ paths: journalPaths(runId, CONFORMANCE_JOURNAL_DIR),
553
+ runId,
554
+ runs,
555
+ waitMs: 600,
556
+ probeIntervalMs: POLL_INTERVAL_MS
557
+ }).then(() => null, (reason) => reason);
558
+ expect(error).toBeInstanceOf(JournalAttachUnavailableError);
559
+ if (!(error instanceof JournalAttachUnavailableError)) return;
560
+ expect(error.reason).toBe("journal-timeout");
561
+ expect(error.message).toContain("600ms");
562
+ } finally {
563
+ await dispose();
564
+ }
565
+ });
566
+ it("lets the second of two concurrent drivers win, and the loser appends nothing at all", { timeout: 18e4 }, async () => {
567
+ const { handle, dispose } = await config.createHandle();
568
+ const runId = uniqueRunId("fence");
569
+ const threadId = `${runId}-t`;
570
+ const deltas = [
571
+ "1",
572
+ "2",
573
+ "3"
574
+ ];
575
+ const expected = expectedTranscript(runId, deltas);
576
+ try {
577
+ const runs = await runningRun(runId, threadId);
578
+ const log = conformanceLog();
579
+ await startJournaledAgent(handle, agentCommand(deltas, deltas.length), { journal: journalOptions(durabilityFor(runs, log.log, false), runId) });
580
+ const released = gate();
581
+ const losingDriver = driverFor({
582
+ handle,
583
+ runs,
584
+ locks: permissiveLocks,
585
+ log: log.log,
586
+ runId,
587
+ attach: false,
588
+ beforeFirstChunk: () => released.promise
589
+ });
590
+ const loser = takeOver(losingDriver.driver, {
591
+ runs,
592
+ runId,
593
+ threadId
594
+ });
595
+ await waitUntil(async () => ((await runs.get(runId))?.driverEpoch ?? 0) >= 1, {
596
+ timeoutMs: 3e4,
597
+ message: `the first driver never claimed run ${runId}`
598
+ });
599
+ const winner = driverFor({
600
+ handle,
601
+ runs,
602
+ locks: permissiveLocks,
603
+ log: log.log,
604
+ runId,
605
+ attach: true
606
+ });
607
+ await takeOver(winner.driver, {
608
+ runs,
609
+ runId,
610
+ threadId
611
+ });
612
+ expect({ backstopped: winner.backstopped() }).toEqual({ backstopped: false });
613
+ expect(transcript(log.stored())).toEqual(transcript(expected));
614
+ released.open();
615
+ await loser;
616
+ expect({ backstopped: losingDriver.backstopped() }).toEqual({ backstopped: false });
617
+ expect(transcript(log.stored())).toEqual(transcript(expected));
618
+ expect(log.stored().some((chunk) => chunk.type === EventType.RUN_ERROR)).toBe(false);
619
+ const record = await runs.get(runId);
620
+ expect(record?.status).toBe("completed");
621
+ expect(record?.error).toBeUndefined();
622
+ expect(record?.driverEpoch).toBe(2);
623
+ expect(log.closes()).toBe(2);
624
+ } finally {
625
+ await cleanup(handle, runId);
626
+ await dispose();
627
+ }
628
+ });
629
+ it("deletes a terminal run's journal, and a later attach reports terminal-run instead of hanging", { timeout: 18e4 }, async () => {
630
+ const { handle, dispose } = await config.createHandle();
631
+ const runId = uniqueRunId("cleanup");
632
+ const threadId = `${runId}-t`;
633
+ const deltas = ["1", "2"];
634
+ const paths = journalPaths(runId, CONFORMANCE_JOURNAL_DIR);
635
+ try {
636
+ const runs = await runningRun(runId, threadId);
637
+ const fresh = durabilityFor(runs, conformanceLog().log, false);
638
+ await startJournaledAgent(handle, agentCommand(deltas, deltas.length), { journal: journalOptions(fresh, runId) });
639
+ const seen = [];
640
+ const backstop = AbortSignal.timeout(READ_BACKSTOP_MS);
641
+ for await (const chunk of translate(runId, readJournalNdjson(handle, {
642
+ signal: backstop,
643
+ journal: journalOptions(fresh, runId)
644
+ }))) seen.push(chunk);
645
+ expect({ backstopped: backstop.aborted }).toEqual({ backstopped: false });
646
+ expect(transcript(seen)).toEqual(transcript(expectedTranscript(runId, deltas)));
647
+ const journalProbe = await handle.process.exec(journalExistsCommand(paths));
648
+ const stderrProbe = await handle.process.exec(journalExistsCommand({
649
+ ...paths,
650
+ journal: paths.stderr
651
+ }));
652
+ expect({
653
+ journalDeleted: journalProbe.exitCode !== 0,
654
+ stderrSidecarDeleted: stderrProbe.exitCode !== 0
655
+ }).toEqual({
656
+ journalDeleted: true,
657
+ stderrSidecarDeleted: true
658
+ });
659
+ await runs.update(runId, {
660
+ status: "completed",
661
+ finishedAt: Date.now()
662
+ });
663
+ const probes = countingExec(handle);
664
+ const error = await awaitAttachableJournal(probes.handle, {
665
+ paths,
666
+ runId,
667
+ runs,
668
+ waitMs: 8e3,
669
+ probeIntervalMs: POLL_INTERVAL_MS
670
+ }).then(() => null, (reason) => reason);
671
+ expect(error).toBeInstanceOf(JournalAttachUnavailableError);
672
+ if (!(error instanceof JournalAttachUnavailableError)) return;
673
+ expect(error.reason).toBe("terminal-run");
674
+ expect(probes.execs()).toBe(1);
675
+ } finally {
676
+ await cleanup(handle, runId);
677
+ await dispose();
678
+ }
679
+ });
680
+ });
681
+ }
682
+ //#endregion
683
+ export { runTakeoverConformance };
684
+
685
+ //# sourceMappingURL=takeover-conformance.js.map