@tanstack/ai-sandbox 0.2.4 → 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/index.ts CHANGED
@@ -1,22 +1,32 @@
1
- // Capability tokens + accessors
1
+ // Capability tokens + accessors (sandbox-owned only).
2
+ // LockStore / withLocks / defineLock: import from @tanstack/ai/locks.
2
3
  export {
3
4
  SandboxCapability,
4
- SandboxStoreCapability,
5
- LocksCapability,
6
5
  SandboxPolicyCapability,
7
6
  ToolBridgeProvisionerCapability,
8
7
  getSandbox,
9
8
  provideSandbox,
10
- getSandboxStore,
11
- provideSandboxStore,
12
- getLocks,
13
- provideLocks,
14
9
  getSandboxPolicy,
15
10
  provideSandboxPolicy,
16
11
  getToolBridgeProvisioner,
17
12
  provideToolBridgeProvisioner,
18
13
  } from './capabilities'
19
14
 
15
+ // Durable instance map (resume-or-create across processes).
16
+ // Pass a store to `withSandbox(sandbox, { instances })`; the capability is the
17
+ // ambient alternative for platform-level wiring.
18
+ export {
19
+ SandboxInstanceStoreCapability,
20
+ getSandboxInstanceStore,
21
+ provideSandboxInstanceStore,
22
+ InMemorySandboxInstanceStore,
23
+ defineSandboxInstanceStore,
24
+ } from './instance-store'
25
+ export type {
26
+ SandboxInstanceStore,
27
+ SandboxInstanceRecord,
28
+ } from './instance-store'
29
+
20
30
  // Workspace projection capability (provided by withSandbox, consumed by harness adapters)
21
31
  export {
22
32
  ProjectionCapability,
@@ -27,6 +37,13 @@ export type { WorkspaceProjection } from './projection'
27
37
 
28
38
  // Middleware
29
39
  export { withSandbox } from './middleware'
40
+ export type { SandboxMiddlewareOptions } from './middleware'
41
+
42
+ // Harness tool history: `withSandbox` records the tool calls a harness ran INSIDE the
43
+ // sandbox into the transcript, so a finished run restores its tool cards. This is how
44
+ // an app recognises them — e.g. in its own `MessageStore.saveThread`, to cap or drop
45
+ // what it does not want to store.
46
+ export { isSandboxToolCall } from './tool-history'
30
47
 
31
48
  // Sandbox definition + lifecycle
32
49
  export { defineSandbox } from './sandbox'
@@ -100,10 +117,6 @@ export type {
100
117
  SandboxDestroyInput,
101
118
  } from './contracts'
102
119
 
103
- // Stores (interfaces + in-memory defaults)
104
- export { InMemorySandboxStore, InMemoryLockStore } from './store'
105
- export type { SandboxStore, LockStore, SandboxRecord } from './store'
106
-
107
120
  // Bootstrap engine (exported for provider/adapter authors + tests)
108
121
  export {
109
122
  bootstrapWorkspace,
@@ -125,8 +138,191 @@ export {
125
138
  export { createExecBackedGit } from './git-exec'
126
139
 
127
140
  // Harness runner: spawn an agent CLI in a sandbox + stream NDJSON stdout
128
- export { spawnNdjson, toLines } from './runner'
129
- export type { SpawnNdjsonOptions } from './runner'
141
+ export {
142
+ spawnNdjson,
143
+ toLines,
144
+ startJournaledAgent,
145
+ readJournalNdjson,
146
+ } from './runner'
147
+ export type { SpawnNdjsonOptions, JournalOptions } from './runner'
148
+
149
+ // The agent output journal: the durability boundary for a sandboxed run.
150
+ //
151
+ // journalListCommand/journalMtimeListCommand/parseJournalMtimeListing back the
152
+ // journal-directory sweep (`journal-sweep.ts`); journalExitProbeCommand/
153
+ // parseJournalExit/parseExitSentinel back the reaper's out-of-band exit probe
154
+ // (`reap.ts`) and the streaming reader, which must agree on what "the run
155
+ // ended" means; EXIT_SENTINEL_NONCE_KEY/exitSentinelLine are what make that
156
+ // sentinel unforgeable by the agent's own stdout and are needed by anything
157
+ // that seeds a journal by hand (a fake host, a test); the
158
+ // mechanism that lets a sweep learn a detached run finished WITHOUT driving
159
+ // it; decodeJournalRunId recovers the runId a listed filename encodes, fail
160
+ // closed. `encodeRunId` is exported for adapters that derive their OWN
161
+ // in-sandbox paths from a caller-supplied `runId` (prompt files, MCP bridge
162
+ // configs) and must not hand-roll a second, divergent encoder.
163
+ // `normalizeJournalDir` is intentionally NOT exported: it is a path
164
+ // formatting detail of this module's own commands, not something a caller
165
+ // composes with.
166
+ export {
167
+ DEFAULT_JOURNAL_DIR,
168
+ encodeRunId,
169
+ EXIT_SENTINEL_KEY,
170
+ EXIT_SENTINEL_NONCE_KEY,
171
+ exitSentinelLine,
172
+ parseExitSentinel,
173
+ journalPaths,
174
+ journaledCommand,
175
+ journalFollowCommand,
176
+ journalReadCommand,
177
+ journalExistsCommand,
178
+ journalListCommand,
179
+ journalMtimeListCommand,
180
+ parseJournalMtimeListing,
181
+ journalExitProbeCommand,
182
+ parseJournalExit,
183
+ decodeJournalRunId,
184
+ } from './journal'
185
+ export type {
186
+ JournalPaths,
187
+ JournalMtimeListing,
188
+ JournalDirEntry,
189
+ DecodedJournalRunId,
190
+ } from './journal'
191
+
192
+ // Journal-directory sweep: bound the journals a detached run's sentinel never
193
+ // got OBSERVED for (see `journal-sweep.ts`'s module doc for why almost every
194
+ // branch keeps rather than deletes — deleting a live run's journal makes it
195
+ // unresumable, with no undo).
196
+ export {
197
+ pruneJournals,
198
+ DEFAULT_ORPHAN_TTL_MS,
199
+ DEFAULT_MAX_DELETES,
200
+ } from './journal-sweep'
201
+ export type {
202
+ PruneJournalsOptions,
203
+ PruneJournalsResult,
204
+ KeptJournal,
205
+ KeptJournalReason,
206
+ PruneJournalsFailure,
207
+ } from './journal-sweep'
208
+
209
+ // Detached-run reaper: sweep a `RunStore`'s reclaimable runs, driving a run to
210
+ // terminal ONLY once the out-of-band journal probe (`probeRunExit`) already
211
+ // knows the agent exited, or once its TTL has expired — never to find out
212
+ // whether it finished (see `reap.ts`'s module doc for why that design was
213
+ // rejected).
214
+ export {
215
+ reapDetachedRuns,
216
+ probeRunExit,
217
+ DEFAULT_RUN_BUDGET_MS,
218
+ DEFAULT_MAX_RUNS,
219
+ DEFAULT_EXIT_PROBE_BYTES,
220
+ } from './reap'
221
+ export type {
222
+ RunExitProbe,
223
+ ReapRunOutcome,
224
+ ReapRunEntry,
225
+ ReapResult,
226
+ ReapOptions,
227
+ } from './reap'
228
+
229
+ // Sandbox reclaim: tear down the sandbox behind a terminal run.
230
+ // `sandboxReclaimer` adapts `reclaimSandbox` to `ReapOptions.reclaim`.
231
+ export {
232
+ reclaimSandbox,
233
+ sandboxReclaimer,
234
+ SandboxReclaimFailedError,
235
+ } from './reclaim'
236
+ export type { ReclaimOutcome, ReclaimSandboxOptions } from './reclaim'
237
+ export {
238
+ DEFAULT_JOURNAL_POLL_MS,
239
+ journalReadStrategy,
240
+ readJournal,
241
+ } from './journal-reader'
242
+ export type { ReadJournalOptions } from './journal-reader'
243
+ export { decodeBase64Stream, toJournalLines } from './journal-bytes'
244
+ export type { JournalLine } from './journal-bytes'
245
+ export {
246
+ createRunScopedIdGen,
247
+ chunkFingerprint,
248
+ chunkFingerprintIgnoringThreadId,
249
+ chunkThreadId,
250
+ } from './chunk-identity'
251
+ export {
252
+ alignToStoredLog,
253
+ isBridgeCustomChunk,
254
+ JournalReplayDivergedError,
255
+ JournalReplayThreadIdMismatchError,
256
+ DEFAULT_MAX_OUT_OF_BAND_SKIP,
257
+ } from './align'
258
+ export type { AlignToStoredLogOptions } from './align'
259
+
260
+ // Attach preflight: the gate that makes a hopeless attach fail instead of
261
+ // tailing an empty journal forever. `JournalAttachUnavailableError` and its
262
+ // `reason` are the branchable surface (404 / 410 / 504 at an attach route), and
263
+ // the bounded-wait default is exported because it bounds an attach REQUEST.
264
+ export {
265
+ awaitAttachableJournal,
266
+ JournalAttachUnavailableError,
267
+ DEFAULT_ATTACH_JOURNAL_WAIT_MS,
268
+ DEFAULT_ATTACH_PROBE_INTERVAL_MS,
269
+ } from './attach-preflight'
270
+ export type {
271
+ AttachUnavailableReason,
272
+ AwaitAttachableJournalOptions,
273
+ } from './attach-preflight'
274
+
275
+ // Durability seam: the `withSandbox(sandbox, { runs, durability })` option
276
+ // shape, the capability harness adapters read back via `getSandboxDurability`,
277
+ // and the two helpers that turn a resolved durability into the pieces a
278
+ // harness adapter actually drives with — a `journalOptionsFor` journal option
279
+ // and an attach-only `alignedIfAttaching` alignment transform.
280
+ //
281
+ // `resolveSandboxDurability` is deliberately NOT exported: it is
282
+ // `withSandbox`'s own path from raw options to the capability payload (see
283
+ // `middleware.ts`), and a harness adapter only ever needs the ALREADY-RESOLVED
284
+ // value read back off the capability bus, never to re-run that resolution
285
+ // itself.
286
+ export {
287
+ SandboxDurabilityCapability,
288
+ getSandboxDurability,
289
+ provideSandboxDurability,
290
+ DurableAttachNotSupportedError,
291
+ DurableRunIdRequiredError,
292
+ DurableThreadIdRequiredError,
293
+ resolveDurableRunId,
294
+ resolveDurableThreadId,
295
+ journalOptionsFor,
296
+ alignedIfAttaching,
297
+ } from './durability'
298
+ export type {
299
+ SandboxDurabilityOptions,
300
+ SandboxDurabilityLog,
301
+ SandboxRunDurability,
302
+ } from './durability'
303
+
304
+ // Run driver: fills in core's injected takeover seams (`claim`/`pipe`) with
305
+ // this package's single-writer claim (`claim.ts`) and run log (`run.ts`), so
306
+ // an application wires `request`/`runs`/`locks`/`durability`/`drive` instead of
307
+ // hand-rolling the claim/fence dance itself.
308
+ //
309
+ // `claim.ts`'s own primitives — `withRunClaim`, `fenceDurability`,
310
+ // `awaitLogQuiescence`, `runDriverLockKey`, and their `RunClaim` /
311
+ // `WithRunClaimOptions` types — are deliberately NOT exported. They are
312
+ // exactly the "easy to get wrong" seam `sandboxRunDriver` exists to make
313
+ // impossible (see `driver.ts`'s module doc, points 1-3), and publishing them
314
+ // would invite the same hand-rolled fencing bugs as a supported path. The two
315
+ // error classes below ARE exported despite that: both can surface through
316
+ // `sandboxRunDriver` itself, so a caller needs `instanceof` to branch on them,
317
+ // and `DEFAULT_FENCE_QUIET_MS` is exported because it is the documented
318
+ // default for `sandboxRunDriver`'s own `fenceQuietMs` option.
319
+ export { sandboxRunDriver, RunDriverPipeOutsideClaimError } from './driver'
320
+ export type { SandboxRunDriverOptions } from './driver'
321
+ export {
322
+ RunClaimNotAcquiredError,
323
+ RunClaimLostError,
324
+ DEFAULT_FENCE_QUIET_MS,
325
+ } from './claim'
130
326
 
131
327
  // MCP tool-proxy bridge (shared by harness adapters): transport-agnostic core
132
328
  // + the node:http host transport + a fetch-friendly JSON-RPC dispatcher.
@@ -173,23 +369,12 @@ export type {
173
369
  ToolExecRequest,
174
370
  } from './remote-tools'
175
371
 
176
- // Resumable run event-log — the primitive that lets a trigger start a run and
177
- // return while a durable orchestrator drives it and clients tail from a cursor.
178
- export { InMemoryRunEventLog, isTerminalRunStatus } from './run-log'
179
- export type {
180
- RunEventLog,
181
- RunRecord,
182
- RunEvent,
183
- RunStatus,
184
- TerminalRunStatus,
185
- RunError,
186
- RunEventLogReadOptions,
187
- } from './run-log'
188
-
189
- // Run driver — pump a chat() stream into the event-log so a trigger returns
372
+ // Run driver — pumps a chat() stream into core's `StreamDurability` and
373
+ // records run status/lifecycle in core's `RunStore`, so a trigger returns
190
374
  // immediately while a durable orchestrator drives the run and clients tail it.
191
375
  export { pipeToRunLog, RunController } from './run'
192
376
  export type {
377
+ RunDeps,
193
378
  PipeToRunLogOptions,
194
379
  RunControllerStartInput,
195
380
  RunHandle,
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Durable sandbox **instance** map — which provider sandbox (and snapshot) to
3
+ * resume for a compound key. Owned by `@tanstack/ai-sandbox` (not chat
4
+ * persistence): domain is runtime placement for `ensure`, not conversation state.
5
+ *
6
+ * Pass to `withSandbox(sandbox, { instances })`, which uses it in `ensure`
7
+ * (in-memory fallback when absent). {@link SandboxInstanceStoreCapability} is
8
+ * the ambient alternative for platform-level wiring.
9
+ */
10
+ import { createCapability } from '@tanstack/ai'
11
+
12
+ /** One persisted sandbox instance, keyed by the compound sandbox instance key. */
13
+ export interface SandboxInstanceRecord {
14
+ /** Compound key (see `computeSandboxKey`). */
15
+ key: string
16
+ /** Provider name that owns `providerSandboxId`. */
17
+ provider: string
18
+ /** Provider-assigned sandbox id used to resume. */
19
+ providerSandboxId: string
20
+ /** Most recent snapshot id, when the provider supports snapshots. */
21
+ latestSnapshotId?: string
22
+ threadId: string
23
+ latestRunId?: string
24
+ /**
25
+ * Epoch ms of last write (for keepAlive / GC by the host app).
26
+ */
27
+ updatedAt: number
28
+ }
29
+
30
+ /**
31
+ * Maps a compound key to the provider sandbox that should be resumed.
32
+ *
33
+ * Implement against your own database (BYO). Prove the contract with
34
+ * `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`.
35
+ */
36
+ export interface SandboxInstanceStore {
37
+ /**
38
+ * Return the record for `key`, or `null` if none exists.
39
+ *
40
+ * INVARIANT: missing keys return `null` (never throw).
41
+ */
42
+ get: (key: string) => Promise<SandboxInstanceRecord | null>
43
+ /**
44
+ * Insert or fully replace the record for `record.key`.
45
+ *
46
+ * INVARIANT (full replace): omitted optional fields (`latestSnapshotId`,
47
+ * `latestRunId`) MUST clear any previously stored values. Do not merge with
48
+ * the prior row — a create-without-snapshot path must not leave a stale
49
+ * snapshot id.
50
+ */
51
+ upsert: (record: SandboxInstanceRecord) => Promise<void>
52
+ /**
53
+ * Remove the record for `key`.
54
+ *
55
+ * INVARIANT: deleting a missing key is a **no-op** (must not throw).
56
+ */
57
+ delete: (key: string) => Promise<void>
58
+ }
59
+
60
+ /**
61
+ * Type a {@link SandboxInstanceStore} implementation inline: pass the object and
62
+ * get autocomplete + contract checking, with no separate
63
+ * `: SandboxInstanceStore` annotation. Hand the result to
64
+ * `withSandbox(sandbox, { instances })`. Matches `defineLock` /
65
+ * `defineMessageStore` style helpers elsewhere in the monorepo.
66
+ */
67
+ export function defineSandboxInstanceStore(
68
+ store: SandboxInstanceStore,
69
+ ): SandboxInstanceStore {
70
+ return store
71
+ }
72
+
73
+ /**
74
+ * Capability for the instance map — the ambient alternative to
75
+ * `withSandbox(sandbox, { instances })`. Provide it from any middleware with
76
+ * {@link provideSandboxInstanceStore}; `withSandbox` reads it when no explicit
77
+ * option was passed.
78
+ */
79
+ export const SandboxInstanceStoreCapability =
80
+ createCapability<SandboxInstanceStore>()('sandbox-instance-store')
81
+
82
+ /** Destructured accessors: `getSandboxInstanceStore` / `provideSandboxInstanceStore`. */
83
+ export const [getSandboxInstanceStore, provideSandboxInstanceStore] =
84
+ SandboxInstanceStoreCapability
85
+
86
+ /** In-memory {@link SandboxInstanceStore}. Resume works only within one process. */
87
+ export class InMemorySandboxInstanceStore implements SandboxInstanceStore {
88
+ private readonly map = new Map<string, SandboxInstanceRecord>()
89
+
90
+ get(key: string): Promise<SandboxInstanceRecord | null> {
91
+ return Promise.resolve(this.map.get(key) ?? null)
92
+ }
93
+
94
+ upsert(record: SandboxInstanceRecord): Promise<void> {
95
+ this.map.set(record.key, record)
96
+ return Promise.resolve()
97
+ }
98
+
99
+ delete(key: string): Promise<void> {
100
+ this.map.delete(key)
101
+ return Promise.resolve()
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Wiring note: hand the store straight to the consumer —
107
+ * `withSandbox(sandbox, { instances: store })`. That cannot be mis-ordered,
108
+ * unlike a separate provider middleware composed after `withSandbox` (which
109
+ * silently degrades to the in-memory fallback).
110
+ *
111
+ * ```ts
112
+ * middleware: [
113
+ * withLocks(locks), // from @tanstack/ai/locks — multi-replica
114
+ * withSandbox(sandbox, { instances: instanceStore }),
115
+ * ]
116
+ * ```
117
+ *
118
+ * For ambient/platform wiring (a hosting layer injecting infra without touching
119
+ * the call site), any middleware may still
120
+ * `provideSandboxInstanceStore(ctx, store)` on the capability bus; an explicit
121
+ * option takes precedence over it.
122
+ */
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Byte-exact framing for journal reads.
3
+ *
4
+ * Both read paths end in {@link toJournalLines}, which counts absolute file
5
+ * offsets over BYTES, because a position is only useful if `tail -c +N` can
6
+ * resume from it. The two paths differ only in how they get bytes:
7
+ *
8
+ * - The **bounded** read is base64-framed (see `journal.ts` rule 2) and arrives
9
+ * as one complete `ExecResult.stdout` string, so {@link decodeBase64Stream}
10
+ * recovers the file's exact bytes regardless of how the provider decoded its
11
+ * stdout.
12
+ * - The **follow** read cannot be base64-framed — the encoder's stdio buffer
13
+ * would swallow the stream — so it arrives as provider-decoded text chunks
14
+ * and {@link encodeUtf8Stream} turns them back into bytes.
15
+ *
16
+ * `atob`, not `Buffer`: this module runs on the host, and the host can itself be
17
+ * a Cloudflare Worker (`ai-sandbox-cloudflare` drives its sandbox over Workers
18
+ * RPC from Worker code), where `Buffer` is not a global unless the `nodejs_compat`
19
+ * flag is on. `atob` is a Web/DOM API available in every host runtime this
20
+ * package targets, so it is the portable choice here.
21
+ */
22
+
23
+ const NEWLINE = 0x0a
24
+
25
+ /** Decode one complete base64 quantum group to bytes. */
26
+ function decodeQuantumGroup(group: string): Uint8Array {
27
+ const binary = atob(group)
28
+ const out = new Uint8Array(binary.length)
29
+ for (let index = 0; index < binary.length; index += 1) {
30
+ out[index] = binary.charCodeAt(index)
31
+ }
32
+ return out
33
+ }
34
+
35
+ /**
36
+ * Decode a streaming base64 frame into raw bytes.
37
+ *
38
+ * Whitespace is stripped because `base64(1)` wraps at 76 columns by default and
39
+ * busybox's build does not accept `-w 0`, so the wrapping cannot be turned off
40
+ * portably. Only complete 4-character quanta are decoded; a remainder is held
41
+ * for the next chunk. Padding (`=`) appears only in the final quantum, so an
42
+ * intermediate group always decodes to exactly 3 bytes.
43
+ */
44
+ export async function* decodeBase64Stream(
45
+ chunks: AsyncIterable<string>,
46
+ ): AsyncIterable<Uint8Array> {
47
+ let pending = ''
48
+ for await (const chunk of chunks) {
49
+ pending += chunk.replace(/\s+/g, '')
50
+ const usable = pending.length - (pending.length % 4)
51
+ if (usable === 0) continue
52
+ const group = pending.slice(0, usable)
53
+ pending = pending.slice(usable)
54
+ yield decodeQuantumGroup(group)
55
+ }
56
+ if (pending.length > 0) {
57
+ // Fail loud. A remainder means the frame was cut off mid-quantum, i.e. the
58
+ // reader died partway through. Rounding it away would silently drop journal
59
+ // bytes and desync every position derived from this stream.
60
+ throw new Error(
61
+ `journal: base64 frame ended mid-quantum with ${pending.length} character(s) pending`,
62
+ )
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Re-encode provider-decoded text chunks as UTF-8 bytes.
68
+ *
69
+ * This is the follow path's replacement for {@link decodeBase64Stream}: the
70
+ * follow command emits the journal's raw bytes, the provider hands them over as
71
+ * `AsyncIterable<string>`, and `TextEncoder` round-trips that text back to the
72
+ * bytes it was decoded from. Chunk boundaries do not need to fall on character
73
+ * boundaries here — {@link toJournalLines} buffers bytes until a newline, so a
74
+ * multi-byte character split across two chunks is reassembled there, exactly as
75
+ * it is on the base64 path.
76
+ *
77
+ * Empty chunks are dropped rather than forwarded: a zero-length `Uint8Array`
78
+ * carries no bytes and would only make the downstream loop spin.
79
+ */
80
+ export async function* encodeUtf8Stream(
81
+ chunks: AsyncIterable<string>,
82
+ ): AsyncIterable<Uint8Array> {
83
+ const encoder = new TextEncoder()
84
+ for await (const chunk of chunks) {
85
+ if (chunk.length === 0) continue
86
+ yield encoder.encode(chunk)
87
+ }
88
+ }
89
+
90
+ /** One complete journal line plus the absolute byte position just past its newline. */
91
+ export interface JournalLine {
92
+ /** The line's text, newline excluded. */
93
+ line: string
94
+ /**
95
+ * Absolute byte offset immediately AFTER this line's newline — i.e. the count
96
+ * of journal bytes fully consumed once this line has been handled, and
97
+ * therefore the exact value to resume a `tail -c +N` from.
98
+ */
99
+ endPosition: number
100
+ }
101
+
102
+ function concatBytes(left: Uint8Array, right: Uint8Array): Uint8Array {
103
+ const out = new Uint8Array(left.length + right.length)
104
+ out.set(left, 0)
105
+ out.set(right, left.length)
106
+ return out
107
+ }
108
+
109
+ /**
110
+ * Split a byte stream into newline-terminated lines, tracking absolute
111
+ * positions from `startPosition`.
112
+ *
113
+ * Deliberately unlike `toLines` in `runner.ts`, which yields a trailing
114
+ * unterminated line: here a trailing partial line is a line the agent is still
115
+ * writing. Yielding it would hand a truncated JSON string downstream AND
116
+ * advance the position past bytes the next read must re-see.
117
+ */
118
+ export async function* toJournalLines(
119
+ byteChunks: AsyncIterable<Uint8Array>,
120
+ startPosition: number,
121
+ ): AsyncIterable<JournalLine> {
122
+ const decoder = new TextDecoder()
123
+ let buffer: Uint8Array = new Uint8Array(0)
124
+ let position = startPosition
125
+ for await (const bytes of byteChunks) {
126
+ buffer = concatBytes(buffer, bytes)
127
+ let newline = buffer.indexOf(NEWLINE)
128
+ while (newline !== -1) {
129
+ const lineBytes = buffer.subarray(0, newline)
130
+ position += newline + 1
131
+ yield { line: decoder.decode(lineBytes), endPosition: position }
132
+ buffer = buffer.slice(newline + 1)
133
+ newline = buffer.indexOf(NEWLINE)
134
+ }
135
+ }
136
+ }