@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
package/src/align.ts ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Replay-from-zero with log alignment: the mechanism that makes a resumed
3
+ * journal read idempotent.
4
+ *
5
+ * A host translates journal bytes 0..1000 and appends the resulting chunks, then
6
+ * dies. A successor re-reads the journal **from byte 0** and re-translates it,
7
+ * producing the same chunks again. This transform reads what is already in the
8
+ * event log, verifies that the replay reproduces it, suppresses that prefix, and
9
+ * passes only the remainder downstream to be appended.
10
+ *
11
+ * Why this shape rather than the offset-upsert the design sketched:
12
+ *
13
+ * - `StreamDurability.append` does not accept caller-supplied offsets, and
14
+ * `UpsertableStreamDurability.upsert` is deliberately **not** used here:
15
+ * `memoryStream.upsert` rejects any offset it did not mint itself, and
16
+ * `durableStream` has no `upsert` at all (its offsets embed a
17
+ * backend-assigned cursor). The journal path therefore only ever *appends*,
18
+ * and this function's whole job is deciding where that append starts. Do not
19
+ * "simplify" it into an `upsert` — the recommended production adapter cannot
20
+ * accept one.
21
+ * - Even if it could, re-translation is only reproducible because
22
+ * `createRunScopedIdGen` makes it so. The dedupe boundary therefore has to be
23
+ * *derived from the log*, not tracked beside it — which also means there is no
24
+ * window in which a checkpoint and the log can disagree, because the log is
25
+ * the checkpoint.
26
+ * - The log stays append-only with strictly increasing offsets. That is what
27
+ * `durableStream`'s backend enforces and what the client's offset de-dup
28
+ * (`ai-client`'s `seen` set) relies on — the client is NOT tolerant of a
29
+ * duplicated text or tool-argument delta.
30
+ *
31
+ * Divergence is a bug, not a condition to recover from, so it throws.
32
+ */
33
+ import { EventType } from '@tanstack/ai'
34
+ import {
35
+ chunkFingerprint,
36
+ chunkFingerprintIgnoringThreadId,
37
+ chunkThreadId,
38
+ } from './chunk-identity'
39
+ import type { InternalLogger } from '@tanstack/ai/adapter-internals'
40
+ import type { StreamChunk, StreamDurability } from '@tanstack/ai'
41
+
42
+ /**
43
+ * Default bound on consecutive stored chunks alignment will skip as out-of-band.
44
+ *
45
+ * A bound is what keeps this a tolerance rather than a search. Unbounded, a
46
+ * genuine determinism regression would make alignment scan forward through the
47
+ * whole log looking for a fingerprint that happens to match, suppress
48
+ * everything it passed, and deliver a stream whose prefix and suffix disagree —
49
+ * the exact failure {@link JournalReplayDivergedError} exists to prevent. 64 is
50
+ * well above any realistic burst of bridged console events between two
51
+ * translated chunks and well below a log length where a false match becomes
52
+ * plausible.
53
+ */
54
+ export const DEFAULT_MAX_OUT_OF_BAND_SKIP = 64
55
+
56
+ /**
57
+ * The out-of-band predicate for the harness adapters.
58
+ *
59
+ * `ai-codex` and `ai-claude-code` splice `createBridgeEventChannel`'s stream
60
+ * into their translated output with `mergeChunkStreams`. That channel is the
61
+ * only producer on the path and it emits exclusively `EventType.CUSTOM` chunks
62
+ * (`bridge-events.ts:53-63`), fired by LIVE bridged-tool execution. A replay
63
+ * runs no tools, so those chunks exist in the log and not in the replay.
64
+ *
65
+ * Structural rather than a list of event names on purpose: a new bridged tool
66
+ * inventing a new `name` must not silently reintroduce the divergence.
67
+ */
68
+ export function isBridgeCustomChunk(chunk: StreamChunk): boolean {
69
+ return chunk.type === EventType.CUSTOM
70
+ }
71
+
72
+ /**
73
+ * The replay produced a different chunk than the log already holds at that
74
+ * index. Means translation stopped being deterministic — a `genId` that is not
75
+ * run-scoped, a translator that consults the clock, or a journal that was
76
+ * rewritten. Fail loud: suppressing the mismatch would deliver a stream whose
77
+ * prefix and suffix disagree about message identity.
78
+ */
79
+ export class JournalReplayDivergedError extends Error {
80
+ constructor(
81
+ readonly index: number,
82
+ readonly stored: string,
83
+ readonly replayed: string,
84
+ ) {
85
+ super(
86
+ `journal replay diverged at index ${index}: stored ${stored} but replayed ${replayed}`,
87
+ )
88
+ this.name = 'JournalReplayDivergedError'
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The replay reproduced the stored chunk EXACTLY except for its `threadId`.
94
+ *
95
+ * A distinct diagnosis because the cause and the fix are entirely different from
96
+ * a real divergence. The adapters resolve `threadId` as
97
+ * `options.threadId ?? this.generateId()`, and that id lands in every emitted
98
+ * chunk — so an attach route that drives a run without passing the run record's
99
+ * `threadId` mints a fresh one, and the very first chunk (`RUN_STARTED`) fails
100
+ * alignment. The agent behaved identically; only the id moved. Reported as a
101
+ * generic divergence, that sends the reader hunting for non-determinism in the
102
+ * translator, which is the wrong place entirely.
103
+ *
104
+ * A SUBCLASS of {@link JournalReplayDivergedError}, deliberately: this is still a
105
+ * divergence and still fatal, so a consumer already branching on the general
106
+ * class keeps working. The two are not collapsed — a genuine content divergence
107
+ * throws the base class, so `instanceof JournalReplayThreadIdMismatchError`
108
+ * separates a config mistake from a determinism bug in exactly one check.
109
+ */
110
+ export class JournalReplayThreadIdMismatchError extends JournalReplayDivergedError {
111
+ constructor(
112
+ index: number,
113
+ stored: string,
114
+ replayed: string,
115
+ readonly storedThreadId: string | undefined,
116
+ readonly replayedThreadId: string | undefined,
117
+ ) {
118
+ super(index, stored, replayed)
119
+ this.name = 'JournalReplayThreadIdMismatchError'
120
+ this.message =
121
+ `journal replay diverged at index ${index} ONLY by threadId: stored ${JSON.stringify(storedThreadId)} but replayed ${JSON.stringify(replayedThreadId)}. ` +
122
+ `Every other field of the chunk is identical, so the agent did NOT behave differently — the attaching run generated a new threadId instead of reusing the run record's. ` +
123
+ `Pass the run record's threadId (RunRecord.threadId, which sandboxRunDriver hands to drive({ runId, threadId, signal })) into chat() on the attach route; ` +
124
+ `without it the adapter falls back to generateId() and every chunk carries an id the stored log cannot match.`
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Classify a mismatch before throwing.
130
+ *
131
+ * The `threadId`-only case is recognized by comparing the two chunks a SECOND
132
+ * time with `threadId` excluded: equal there and unequal under the real
133
+ * fingerprint means `threadId` is the only field that moved. Cheap, because it
134
+ * runs only on the failure path, and precise, because it is derived from the same
135
+ * fingerprint function rather than a hand-written field diff.
136
+ */
137
+ function divergenceError(
138
+ index: number,
139
+ storedChunk: StreamChunk,
140
+ replayedChunk: StreamChunk,
141
+ stored: string,
142
+ replayed: string,
143
+ ): JournalReplayDivergedError {
144
+ const storedThreadId = chunkThreadId(storedChunk)
145
+ const replayedThreadId = chunkThreadId(replayedChunk)
146
+ if (
147
+ storedThreadId !== replayedThreadId &&
148
+ chunkFingerprintIgnoringThreadId(storedChunk) ===
149
+ chunkFingerprintIgnoringThreadId(replayedChunk)
150
+ ) {
151
+ return new JournalReplayThreadIdMismatchError(
152
+ index,
153
+ stored,
154
+ replayed,
155
+ storedThreadId,
156
+ replayedThreadId,
157
+ )
158
+ }
159
+ return new JournalReplayDivergedError(index, stored, replayed)
160
+ }
161
+
162
+ export interface AlignToStoredLogOptions<TOffset extends string = string> {
163
+ /**
164
+ * The run's event log. Read from the beginning; never written here.
165
+ *
166
+ * Generic in the offset type, defaulted to `string`, for the same reason
167
+ * {@link RunDeps} is: a branded-cursor backend's `StreamDurability<TOffset>`
168
+ * is not assignable to `StreamDurability<string>`.
169
+ *
170
+ * Narrowed to `snapshot` — the only member this transform touches, as the
171
+ * function docs below spell out — so the capability-bus view of a log
172
+ * (`SandboxDurabilityLog`, which omits the offset-invariant `read`) can be
173
+ * passed straight through by `alignedIfAttaching`. A full `StreamDurability`
174
+ * still satisfies it, so no existing caller changes.
175
+ */
176
+ durability: Pick<StreamDurability<TOffset>, 'snapshot'>
177
+ /** Optional sink for the alignment summary. */
178
+ logger?: InternalLogger
179
+ /**
180
+ * Recognizes a stored chunk that the replay CANNOT reproduce, so alignment
181
+ * skips it instead of throwing.
182
+ *
183
+ * Absent by default, which keeps strict positional comparison: any stored
184
+ * chunk the replay does not produce is a determinism bug and fails loudly.
185
+ * Pass {@link isBridgeCustomChunk} on the harness attach path, where the
186
+ * previous host spliced live bridged-tool events into the log.
187
+ *
188
+ * The predicate is applied to the STORED chunk, never to the replayed one. A
189
+ * skipped entry is suppressed, not re-appended, so the client's view is
190
+ * unchanged: it already received that chunk under its own offset.
191
+ */
192
+ isOutOfBand?: (chunk: StreamChunk) => boolean
193
+ /**
194
+ * Maximum CONSECUTIVE stored chunks that may be skipped as out-of-band before
195
+ * alignment gives up. Reset by every match. Defaults to
196
+ * {@link DEFAULT_MAX_OUT_OF_BAND_SKIP}. Ignored when `isOutOfBand` is absent.
197
+ */
198
+ maxOutOfBandSkip?: number
199
+ }
200
+
201
+ /**
202
+ * Suppress the chunks already present in the event log and yield the rest.
203
+ *
204
+ * The stored prefix is read exactly once, eagerly, before the first replay
205
+ * chunk is pulled. Both halves of that matter:
206
+ *
207
+ * - **Exactly once**, because a second read mid-stream would race the appends
208
+ * the caller is making downstream of this transform and could classify a
209
+ * chunk this very run just appended as an already-stored one, dropping it.
210
+ * - **Via `snapshot()`, never `read()`**. `read` *tails*: it returns only when
211
+ * the log is terminalized with `close()` or the caller aborts. A takeover's
212
+ * log is open by definition — the host that would have closed it is the host
213
+ * that died — so `for await (… of read('-1'))` would never finish, and on an
214
+ * empty log `memoryStream` rejects a from-start join outright once its
215
+ * first-chunk deadline elapses. `snapshot()` is the bounded read: it resolves
216
+ * with what is stored right now, including while the log is still open, and
217
+ * resolves to `[]` for a run with nothing stored.
218
+ */
219
+ export async function* alignToStoredLog<TOffset extends string = string>(
220
+ chunks: AsyncIterable<StreamChunk>,
221
+ options: AlignToStoredLogOptions<TOffset>,
222
+ ): AsyncIterable<StreamChunk> {
223
+ const entries = await options.durability.snapshot()
224
+ const stored = entries.map((entry) => chunkFingerprint(entry.chunk))
225
+
226
+ const isOutOfBand = options.isOutOfBand
227
+ const maxSkip = options.maxOutOfBandSkip ?? DEFAULT_MAX_OUT_OF_BAND_SKIP
228
+
229
+ let cursor = 0
230
+ let suppressed = 0
231
+ let skipped = 0
232
+ let forwarded = 0
233
+
234
+ for await (const chunk of chunks) {
235
+ // Past the end of the stored log: everything from here is new.
236
+ if (cursor >= stored.length) {
237
+ forwarded += 1
238
+ yield chunk
239
+ continue
240
+ }
241
+
242
+ const actual = chunkFingerprint(chunk)
243
+ let consecutiveSkips = 0
244
+ for (;;) {
245
+ // `entries` and `stored` are the same length by construction; both are
246
+ // bound because the predicate needs the CHUNK while the comparison needs
247
+ // its fingerprint.
248
+ const entry = entries[cursor]
249
+ const expected = stored[cursor]
250
+ if (entry === undefined || expected === undefined) {
251
+ forwarded += 1
252
+ yield chunk
253
+ break
254
+ }
255
+ if (expected === actual) {
256
+ cursor += 1
257
+ suppressed += 1
258
+ break
259
+ }
260
+ // Mismatch. Only a stored chunk the replay provably cannot reproduce may
261
+ // be skipped, and only `maxSkip` of them in a row.
262
+ if (isOutOfBand === undefined || !isOutOfBand(entry.chunk)) {
263
+ throw divergenceError(cursor, entry.chunk, chunk, expected, actual)
264
+ }
265
+ if (consecutiveSkips >= maxSkip) {
266
+ throw divergenceError(cursor, entry.chunk, chunk, expected, actual)
267
+ }
268
+ cursor += 1
269
+ consecutiveSkips += 1
270
+ skipped += 1
271
+ }
272
+ }
273
+
274
+ // Trailing stored entries. Out-of-band ones are expected (a bridged tool's
275
+ // last event lands after the final translated chunk); anything else means the
276
+ // journal no longer accounts for chunks the log already delivered, which
277
+ // nothing downstream can repair.
278
+ while (cursor < stored.length) {
279
+ const entry = entries[cursor]
280
+ if (
281
+ entry === undefined ||
282
+ isOutOfBand === undefined ||
283
+ !isOutOfBand(entry.chunk)
284
+ ) {
285
+ throw new Error(
286
+ `journal replay is shorter than the stored log: ${stored.length - cursor} stored chunk(s) from index ${cursor} were not reproduced`,
287
+ )
288
+ }
289
+ cursor += 1
290
+ skipped += 1
291
+ }
292
+
293
+ options.logger?.provider(
294
+ `journal alignment: suppressed ${suppressed} stored chunk(s), skipped ${skipped} out-of-band, forwarded ${forwarded}`,
295
+ { suppressed, skipped, forwarded },
296
+ )
297
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * The gate that turns a HOPELESS attach into an error instead of an infinite
3
+ * wait.
4
+ *
5
+ * `journalFollowCommand` creates the journal before tailing it (`: >> file`),
6
+ * because `tail -f` on a missing path prints a diagnostic and EXITS rather than
7
+ * waiting — a defect that made a legitimate attach racing the driver's first
8
+ * write deliver zero lines. But creating the file has a cost: an attach for a
9
+ * `runId` that never had a journal creates an EMPTY one and tails it forever. No
10
+ * `{"__exit":N}` sentinel can ever arrive, so the caller waits indefinitely with
11
+ * no error, no timeout, and no log line — for what is the single most likely
12
+ * mistake on this path (a stale link, a typo, a run whose journal was cleaned up
13
+ * after completing).
14
+ *
15
+ * Absence of the journal alone cannot decide the question, which is exactly why
16
+ * `: >> file` exists: "not written YET" and "will never be written" look
17
+ * identical on the filesystem. The RUN RECORD is what distinguishes them, and it
18
+ * is authoritative — `runs.get(runId)` says whether the run exists at all,
19
+ * whether it is terminal, and (via `detachedSince`) whether anyone is expected
20
+ * to be driving it. So the policy is:
21
+ *
22
+ * | journal | record | decision |
23
+ * | --------- | ----------------------- | ------------------------------------- |
24
+ * | exists | (not consulted) | attach, under the reader's own bound |
25
+ * | absent | unknown (`null`) | fail fast, `'unknown-run'` |
26
+ * | absent | terminal | fail fast, `'terminal-run'` |
27
+ * | absent | running / interrupted | BOUNDED wait, then `'journal-timeout'`|
28
+ * | unusable | running / interrupted | BOUNDED wait, then `'journal-timeout'`|
29
+ *
30
+ * Four deliberate choices in that table:
31
+ *
32
+ * 1. **An existing journal short-circuits this gate**, before the store is read
33
+ * at all — because gating it would make a perfectly readable journal
34
+ * unreadable whenever a store lost its record. It does NOT mean the read is
35
+ * unbounded: this module used to justify the short-circuit with "a journal
36
+ * that exists either carries a sentinel or is still being appended to, neither
37
+ * hangs", and that trichotomy was FALSE. `journalFollowCommand`'s first act is
38
+ * `: >> file`, so the reader itself manufactures the third state — a file that
39
+ * exists, receives nothing, and can never receive a sentinel — and the same
40
+ * state is independently reachable by SIGKILL/OOM of the agent's shell before
41
+ * its `printf`. The bound for it lives where it belongs, on the read:
42
+ * `journal-reader.ts` fails a follow/poll that receives no bytes at all within
43
+ * {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} with `'journal-stalled'`.
44
+ * 2. **A terminal record with no journal fails rather than waiting.** Nothing
45
+ * will ever be appended: the run is over and `journalCleanupCommand` deletes a
46
+ * terminal run's files by design. Its transcript lives in the event log, which
47
+ * the resume response serves independently of this path.
48
+ * 3. **A live or detached record waits, but not forever.** A driver that has
49
+ * claimed the run and not yet written its first line is the normal case, not
50
+ * the unlucky one, so failing fast here would break the very race
51
+ * `journalFollowCommand` was fixed to tolerate. `detachedSince` does NOT
52
+ * change the decision — a detached run's journal is exactly what a successor
53
+ * is supposed to read, and a driver that died before its first write leaves an
54
+ * identical filesystem state — but it IS reported in the timeout message,
55
+ * since "detached with no journal after N ms" and "attached with no journal
56
+ * after N ms" point at different causes.
57
+ * 4. **An UNUSABLE probe falls through to the bounded wait; it does not skip the
58
+ * gate.** This module used to fail open here — `if (existence === 'unknown')
59
+ * return` — on the reasoning that a diagnostic gate must not break an attach
60
+ * that would otherwise have worked. That reasoning inverted the actual risk.
61
+ * Returning handed control to a reader whose very first act CREATES the
62
+ * journal (`journalFollowCommand`'s `: >> file`) and then tails it forever, so
63
+ * the fail-open path did not preserve a working attach — it manufactured the
64
+ * exact infinite wait this module exists to prevent. Worse, it was
65
+ * self-perpetuating: the file it created made `test -f` succeed from then on,
66
+ * so every LATER attach short-circuited at choice 1 and hung too, permanently,
67
+ * long after the transient probe failure had cleared. An unanswerable probe is
68
+ * precisely when a deadline matters most, so an unusable probe is re-polled
69
+ * (it may recover) and, failing that, times out. The store checks still run
70
+ * first and need no probe, so an unknown or terminal `runId` still fails fast.
71
+ */
72
+ import { isTerminalRunStatus } from '@tanstack/ai'
73
+ import { journalExistsCommand } from './journal'
74
+ import type { JournalPaths } from './journal'
75
+ import type { SandboxHandle } from './contracts'
76
+ import type { InternalLogger } from '@tanstack/ai/adapter-internals'
77
+ import type { RunRecord, RunStore } from '@tanstack/ai'
78
+
79
+ /**
80
+ * How long an attach waits for a live run's journal to appear before failing.
81
+ *
82
+ * User-relevant, hence exported: this bounds how long an attach REQUEST can sit
83
+ * before it answers, so an application that fronts the attach route with its own
84
+ * timeout needs to know the number. Generous relative to the gap between a
85
+ * driver claiming a run and its first journal write (a `spawn` plus one line),
86
+ * and short relative to any sane HTTP timeout. Override per run with
87
+ * `SandboxDurabilityOptions.attachWaitMs`.
88
+ */
89
+ export const DEFAULT_ATTACH_JOURNAL_WAIT_MS = 10_000
90
+
91
+ /**
92
+ * How often the bounded wait re-probes for the journal. Not user-facing: it
93
+ * trades a `test -f` per interval for attach latency, and neither number is
94
+ * something an application tunes.
95
+ */
96
+ export const DEFAULT_ATTACH_PROBE_INTERVAL_MS = 100
97
+
98
+ /**
99
+ * Which of the three hopeless-attach cases was hit. Exported so a consumer can
100
+ * branch (a 404 for `'unknown-run'`, a 410 for `'terminal-run'`, a 504 for
101
+ * `'journal-timeout'`) instead of matching on message text.
102
+ */
103
+ export type AttachUnavailableReason =
104
+ | 'unknown-run'
105
+ | 'terminal-run'
106
+ | 'journal-timeout'
107
+ /**
108
+ * The journal EXISTS but produced no bytes at all within the deadline, so no
109
+ * sentinel can be coming and the follow would tail an empty (or abandoned)
110
+ * file forever. Raised by `journal-reader.ts`, not by the preflight: the
111
+ * preflight cannot see this state, because `test -f` succeeds for it.
112
+ *
113
+ * A 504 at an attach route, exactly like `'journal-timeout'`, which is why it
114
+ * shares {@link JournalAttachUnavailableError} — but a distinct value, because
115
+ * the cause is different: `'journal-timeout'` means nobody created the
116
+ * journal, `'journal-stalled'` means somebody did and then stopped (a
117
+ * SIGKILLed agent shell, a destroyed sandbox, a reader that created the file
118
+ * itself on a fail-open path).
119
+ */
120
+ | 'journal-stalled'
121
+
122
+ /**
123
+ * An attach cannot succeed, and waiting longer would not change that.
124
+ *
125
+ * One class with a {@link AttachUnavailableReason} discriminant rather than three
126
+ * classes: every consumer of this path handles all three cases at the same seam
127
+ * (the attach route), so one `instanceof` plus a `switch (error.reason)` is the
128
+ * shape that is actually written, while the message names the specific case for a
129
+ * human reading a log.
130
+ */
131
+ export class JournalAttachUnavailableError extends Error {
132
+ constructor(
133
+ readonly runId: string,
134
+ readonly reason: AttachUnavailableReason,
135
+ detail: string,
136
+ ) {
137
+ super(`cannot attach to run ${runId}: ${detail}`)
138
+ this.name = 'JournalAttachUnavailableError'
139
+ }
140
+ }
141
+
142
+ /** Existence of the journal, or `'unknown'` when the probe itself failed. */
143
+ type JournalExistence = 'yes' | 'no' | 'unknown'
144
+
145
+ export interface AwaitAttachableJournalOptions {
146
+ /** The run's journal paths, as {@link journalPaths} derived them. */
147
+ paths: JournalPaths
148
+ /** Run id, for the store lookup and the error messages. */
149
+ runId: string
150
+ /**
151
+ * The authoritative run record store. Omitted only by a caller with no store
152
+ * wired, which loses the unknown/terminal classification but keeps the bound.
153
+ */
154
+ runs?: RunStore
155
+ /** Bounded wait. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}. */
156
+ waitMs?: number
157
+ /** Re-probe interval. Defaults to {@link DEFAULT_ATTACH_PROBE_INTERVAL_MS}. */
158
+ probeIntervalMs?: number
159
+ /**
160
+ * The consumer's abort. An aborted wait returns rather than throwing: the
161
+ * caller stopped caring, which is not a diagnosis about the run.
162
+ */
163
+ signal?: AbortSignal
164
+ logger?: InternalLogger
165
+ }
166
+
167
+ /**
168
+ * Shell `test -f`, never `handle.fs.exists` — `journal.ts` rule 3: on
169
+ * local-process the two resolve `/tmp` differently, so `fs.exists` would probe a
170
+ * path the journal was never written to and report `false` for every run.
171
+ */
172
+ async function probeJournal(
173
+ handle: SandboxHandle,
174
+ options: AwaitAttachableJournalOptions,
175
+ ): Promise<JournalExistence> {
176
+ try {
177
+ const result = await handle.process.exec(
178
+ journalExistsCommand(options.paths),
179
+ )
180
+ return result.exitCode === 0 ? 'yes' : 'no'
181
+ } catch (error) {
182
+ options.logger?.provider(
183
+ `attach preflight: journal existence probe failed for run ${options.runId}; re-probing under the bounded wait rather than attaching blind`,
184
+ { runId: options.runId, error },
185
+ )
186
+ return 'unknown'
187
+ }
188
+ }
189
+
190
+ /**
191
+ * `null` means the store answered "no such run" — a real, actionable fact.
192
+ * `undefined` means there is no answer to be had (no store, or `get` threw), and
193
+ * the caller must not treat that as "unknown run".
194
+ */
195
+ async function readRecord(
196
+ options: AwaitAttachableJournalOptions,
197
+ ): Promise<RunRecord | null | undefined> {
198
+ if (options.runs === undefined) return undefined
199
+ try {
200
+ return await options.runs.get(options.runId)
201
+ } catch (error) {
202
+ options.logger?.errors(
203
+ `attach preflight: reading the run record failed for run ${options.runId}`,
204
+ { runId: options.runId, error },
205
+ )
206
+ return undefined
207
+ }
208
+ }
209
+
210
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
211
+ if (ms <= 0) return Promise.resolve()
212
+ return new Promise<void>((resolve) => {
213
+ const timer = setTimeout(finish, ms)
214
+ function finish(): void {
215
+ clearTimeout(timer)
216
+ signal?.removeEventListener('abort', finish)
217
+ resolve()
218
+ }
219
+ signal?.addEventListener('abort', finish, { once: true })
220
+ })
221
+ }
222
+
223
+ function describeRecord(record: RunRecord): string {
224
+ return record.detachedSince === undefined
225
+ ? `status '${record.status}' with a viewer attached`
226
+ : `status '${record.status}', detached since ${new Date(record.detachedSince).toISOString()}`
227
+ }
228
+
229
+ /**
230
+ * Resolve once the run's journal can be tailed, or reject with a
231
+ * {@link JournalAttachUnavailableError} explaining why it never will be.
232
+ *
233
+ * Call this BEFORE the first follow/poll read of an attach, never on a fresh
234
+ * run: a fresh run's journal is created by its own `journaledCommand` spawn,
235
+ * which has not happened yet, so gating it would fail every new run.
236
+ */
237
+ export async function awaitAttachableJournal(
238
+ handle: SandboxHandle,
239
+ options: AwaitAttachableJournalOptions,
240
+ ): Promise<void> {
241
+ const existence = await probeJournal(handle, options)
242
+ if (existence === 'yes') return
243
+
244
+ const record = await readRecord(options)
245
+ if (record === null) {
246
+ throw new JournalAttachUnavailableError(
247
+ options.runId,
248
+ 'unknown-run',
249
+ `no run record exists and the journal (${options.paths.journal}) has never been written, so nothing will ever be appended to it. ` +
250
+ `The runId is unknown to the RunStore — it is mistyped, from another deployment, or its record has been evicted.`,
251
+ )
252
+ }
253
+ if (record !== undefined && isTerminalRunStatus(record.status)) {
254
+ throw new JournalAttachUnavailableError(
255
+ options.runId,
256
+ 'terminal-run',
257
+ `the run is already '${record.status}' and its journal (${options.paths.journal}) does not exist, so nothing will ever be appended to it. ` +
258
+ `A terminal run's transcript lives in its event log, not in a journal — serve the log instead of attaching.`,
259
+ )
260
+ }
261
+
262
+ // NOTE: no fail-open branch here. An `existence === 'unknown'` probe falls
263
+ // through into the bounded wait below — see choice 4 in the module doc for why
264
+ // returning was worse than timing out, not safer.
265
+ const waitMs = options.waitMs ?? DEFAULT_ATTACH_JOURNAL_WAIT_MS
266
+ const probeIntervalMs =
267
+ options.probeIntervalMs ?? DEFAULT_ATTACH_PROBE_INTERVAL_MS
268
+ const deadline = Date.now() + waitMs
269
+ let lastExistence: JournalExistence = existence
270
+ for (;;) {
271
+ const remaining = deadline - Date.now()
272
+ if (remaining <= 0) {
273
+ throw new JournalAttachUnavailableError(
274
+ options.runId,
275
+ 'journal-timeout',
276
+ `the run record says ${record === undefined ? 'nothing (no run store is wired)' : describeRecord(record)}, ` +
277
+ (lastExistence === 'unknown'
278
+ ? `and its journal (${options.paths.journal}) could not be probed at all within ${waitMs}ms — every '${journalExistsCommand(options.paths)}' failed. ` +
279
+ `Attaching anyway would create that journal and tail it forever, so this fails instead. Check that the sandbox is still alive and that its exec transport works.`
280
+ : `but its journal (${options.paths.journal}) did not appear within ${waitMs}ms. ` +
281
+ `Either the driver died before writing its first line, or the journal directory does not match the one the agent was started with.`),
282
+ )
283
+ }
284
+ // The consumer gave up (client gone, lease lost). Returning hands control
285
+ // back to the reader, whose own AbortSignal handling ends the read — a
286
+ // caller's abort is not a diagnosis about the run.
287
+ if (options.signal?.aborted) return
288
+ await sleep(Math.min(probeIntervalMs, remaining), options.signal)
289
+ lastExistence = await probeJournal(handle, options)
290
+ if (lastExistence === 'yes') return
291
+ }
292
+ }
@@ -1,26 +1,19 @@
1
1
  /**
2
- * Capability tokens the sandbox layer provides/consumes through the
3
- * `@tanstack/ai` middleware capability system.
2
+ * Capability tokens the sandbox layer owns and provides.
4
3
  *
5
4
  * - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness
6
5
  * adapters (`requires: [SandboxCapability]`).
7
- * - `SandboxStoreCapability` / `LocksCapability` are OPTIONALLY required by
8
- * `withSandbox`. v1 falls back to in-memory defaults; the future persistence
9
- * package PROVIDES durable implementations.
6
+ * - `SandboxInstanceStoreCapability` lives in
7
+ * `./instance-store` (same package). `LocksCapability` / `withLocks` live in
8
+ * `@tanstack/ai/locks` and are not re-exported here.
10
9
  */
11
10
  import { createCapability } from '@tanstack/ai'
12
11
  import type { SandboxHandle } from './contracts'
13
- import type { LockStore, SandboxStore } from './store'
14
12
  import type { SandboxPolicy } from './policy'
15
13
  import type { ToolBridgeProvisioner } from './tool-bridge'
16
14
 
17
15
  export const SandboxCapability = createCapability<SandboxHandle>()('sandbox')
18
16
 
19
- export const SandboxStoreCapability =
20
- createCapability<SandboxStore>()('sandbox-store')
21
-
22
- export const LocksCapability = createCapability<LockStore>()('locks')
23
-
24
17
  /**
25
18
  * The active sandbox policy, provided by `withSandbox` from the definition.
26
19
  * Harness adapters read it to map allow/ask/deny rules onto their native
@@ -40,8 +33,6 @@ export const ToolBridgeProvisionerCapability =
40
33
 
41
34
  /** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */
42
35
  export const [getSandbox, provideSandbox] = SandboxCapability
43
- export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability
44
- export const [getLocks, provideLocks] = LocksCapability
45
36
  export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability
46
37
  export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] =
47
38
  ToolBridgeProvisionerCapability