@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.
- package/dist/esm/agents-file.js +53 -34
- package/dist/esm/agents-file.js.map +1 -1
- package/dist/esm/align.d.ts +121 -0
- package/dist/esm/align.js +197 -0
- package/dist/esm/align.js.map +1 -0
- package/dist/esm/approvals.js +63 -29
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/attach-preflight.d.ts +85 -0
- package/dist/esm/attach-preflight.js +189 -0
- package/dist/esm/attach-preflight.js.map +1 -0
- package/dist/esm/bootstrap.js +103 -117
- package/dist/esm/bootstrap.js.map +1 -1
- package/dist/esm/bridge-events.js +96 -71
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/capabilities.d.ts +0 -5
- package/dist/esm/capabilities.js +32 -28
- package/dist/esm/capabilities.js.map +1 -1
- package/dist/esm/chunk-identity.d.ts +52 -0
- package/dist/esm/chunk-identity.js +102 -0
- package/dist/esm/chunk-identity.js.map +1 -0
- package/dist/esm/claim.d.ts +187 -0
- package/dist/esm/claim.js +349 -0
- package/dist/esm/claim.js.map +1 -0
- package/dist/esm/contracts.d.ts +13 -0
- package/dist/esm/driver.d.ts +83 -0
- package/dist/esm/driver.js +138 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/durability.d.ts +263 -0
- package/dist/esm/durability.js +230 -0
- package/dist/esm/durability.js.map +1 -0
- package/dist/esm/errors.js +28 -24
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/file-diff.js +151 -135
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/git-exec.js +51 -62
- package/dist/esm/git-exec.js.map +1 -1
- package/dist/esm/harness-cwd.js +24 -19
- package/dist/esm/harness-cwd.js.map +1 -1
- package/dist/esm/index.d.ts +30 -8
- package/dist/esm/index.js +23 -91
- package/dist/esm/instance-store.d.ts +88 -0
- package/dist/esm/instance-store.js +67 -0
- package/dist/esm/instance-store.js.map +1 -0
- package/dist/esm/journal-bytes.d.ts +67 -0
- package/dist/esm/journal-bytes.js +110 -0
- package/dist/esm/journal-bytes.js.map +1 -0
- package/dist/esm/journal-reader.d.ts +66 -0
- package/dist/esm/journal-reader.js +228 -0
- package/dist/esm/journal-reader.js.map +1 -0
- package/dist/esm/journal-sweep.d.ts +113 -0
- package/dist/esm/journal-sweep.js +309 -0
- package/dist/esm/journal-sweep.js.map +1 -0
- package/dist/esm/journal.d.ts +542 -0
- package/dist/esm/journal.js +679 -0
- package/dist/esm/journal.js.map +1 -0
- package/dist/esm/key.js +36 -33
- package/dist/esm/key.js.map +1 -1
- package/dist/esm/middleware.d.ts +50 -2
- package/dist/esm/middleware.js +335 -208
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.js +75 -49
- package/dist/esm/ngrok.js.map +1 -1
- package/dist/esm/policy.js +43 -34
- package/dist/esm/policy.js.map +1 -1
- package/dist/esm/projection.js +16 -8
- package/dist/esm/projection.js.map +1 -1
- package/dist/esm/reap.d.ts +238 -0
- package/dist/esm/reap.js +355 -0
- package/dist/esm/reap.js.map +1 -0
- package/dist/esm/reclaim.d.ts +84 -0
- package/dist/esm/reclaim.js +106 -0
- package/dist/esm/reclaim.js.map +1 -0
- package/dist/esm/remote-tools.js +73 -62
- package/dist/esm/remote-tools.js.map +1 -1
- package/dist/esm/run.d.ts +93 -25
- package/dist/esm/run.js +274 -79
- package/dist/esm/run.js.map +1 -1
- package/dist/esm/runner.d.ts +119 -2
- package/dist/esm/runner.js +270 -51
- package/dist/esm/runner.js.map +1 -1
- package/dist/esm/sandbox.d.ts +3 -2
- package/dist/esm/sandbox.js +139 -123
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/secrets.js +39 -47
- package/dist/esm/secrets.js.map +1 -1
- package/dist/esm/setup-plan.js +22 -14
- package/dist/esm/setup-plan.js.map +1 -1
- package/dist/esm/shell.d.ts +8 -0
- package/dist/esm/shell.js +197 -158
- package/dist/esm/shell.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +16 -0
- package/dist/esm/testkit/conformance.js +97 -0
- package/dist/esm/testkit/conformance.js.map +1 -0
- package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
- package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
- package/dist/esm/testkit/journal-conformance.d.ts +51 -0
- package/dist/esm/testkit/journal-conformance.js +378 -0
- package/dist/esm/testkit/journal-conformance.js.map +1 -0
- package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
- package/dist/esm/testkit/reaper-conformance.js +847 -0
- package/dist/esm/testkit/reaper-conformance.js.map +1 -0
- package/dist/esm/testkit/shell-spawn.d.ts +2 -0
- package/dist/esm/testkit/shell-spawn.js +60 -0
- package/dist/esm/testkit/shell-spawn.js.map +1 -0
- package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
- package/dist/esm/testkit/takeover-conformance.js +685 -0
- package/dist/esm/testkit/takeover-conformance.js.map +1 -0
- package/dist/esm/tool-bridge.js +227 -180
- package/dist/esm/tool-bridge.js.map +1 -1
- package/dist/esm/tool-history.d.ts +62 -0
- package/dist/esm/tool-history.js +171 -0
- package/dist/esm/tool-history.js.map +1 -0
- package/dist/esm/watch.js +310 -236
- package/dist/esm/watch.js.map +1 -1
- package/dist/esm/workspace.d.ts +1 -1
- package/dist/esm/workspace.js +49 -28
- package/dist/esm/workspace.js.map +1 -1
- package/package.json +16 -6
- package/skills/ai-sandbox/SKILL.md +658 -20
- package/src/align.ts +297 -0
- package/src/attach-preflight.ts +292 -0
- package/src/capabilities.ts +4 -13
- package/src/chunk-identity.ts +154 -0
- package/src/claim.ts +479 -0
- package/src/contracts.ts +13 -0
- package/src/driver.ts +205 -0
- package/src/durability.ts +380 -0
- package/src/index.ts +212 -27
- package/src/instance-store.ts +122 -0
- package/src/journal-bytes.ts +136 -0
- package/src/journal-reader.ts +359 -0
- package/src/journal-sweep.ts +406 -0
- package/src/journal.ts +875 -0
- package/src/middleware.ts +470 -30
- package/src/reap.ts +723 -0
- package/src/reclaim.ts +191 -0
- package/src/run.ts +365 -75
- package/src/runner.ts +347 -3
- package/src/sandbox.ts +38 -8
- package/src/shell.ts +106 -38
- package/src/testkit/conformance.ts +117 -0
- package/src/testkit/durable-run-fields-conformance.ts +147 -0
- package/src/testkit/journal-conformance.ts +676 -0
- package/src/testkit/reaper-conformance.ts +1201 -0
- package/src/testkit/shell-spawn.ts +67 -0
- package/src/testkit/takeover-conformance.ts +1040 -0
- package/src/tool-history.ts +245 -0
- package/src/workspace.ts +1 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/run-log.d.ts +0 -81
- package/dist/esm/run-log.js +0 -107
- package/dist/esm/run-log.js.map +0 -1
- package/dist/esm/store.d.ts +0 -53
- package/dist/esm/store.js +0 -34
- package/dist/esm/store.js.map +0 -1
- package/src/run-log.ts +0 -224
- package/src/store.ts +0 -83
package/src/runner.ts
CHANGED
|
@@ -6,8 +6,27 @@
|
|
|
6
6
|
*
|
|
7
7
|
* This is intentionally transport-minimal: a stdout NDJSON pipe. Multi-client
|
|
8
8
|
* reconnect / replay belongs to the persistence/EventLog layer, not here.
|
|
9
|
+
*
|
|
10
|
+
* The `journal` option adds a second, opt-in transport: instead of holding the
|
|
11
|
+
* agent's stdout pipe directly, the host redirects it into an append-only file
|
|
12
|
+
* inside the sandbox and tails that file. See `journal.ts` for why (host death
|
|
13
|
+
* cannot SIGPIPE the agent, and a later host can resume the same file from byte
|
|
14
|
+
* 0). `spawnNdjson`'s signature and unjournaled behavior are unchanged; every
|
|
15
|
+
* existing caller keeps working exactly as before.
|
|
9
16
|
*/
|
|
17
|
+
import {
|
|
18
|
+
journalCleanupCommand,
|
|
19
|
+
journalPaths,
|
|
20
|
+
journalStderrReadCommand,
|
|
21
|
+
journaledCommand,
|
|
22
|
+
parseExitSentinel,
|
|
23
|
+
} from './journal'
|
|
24
|
+
import { readJournal } from './journal-reader'
|
|
25
|
+
import { decodeBase64Stream } from './journal-bytes'
|
|
26
|
+
import { awaitAttachableJournal } from './attach-preflight'
|
|
27
|
+
import type { JournalPaths } from './journal'
|
|
10
28
|
import type { ProcessOptions, SandboxHandle } from './contracts'
|
|
29
|
+
import type { RunStore } from '@tanstack/ai'
|
|
11
30
|
|
|
12
31
|
export interface SpawnNdjsonOptions extends ProcessOptions {
|
|
13
32
|
/**
|
|
@@ -20,6 +39,97 @@ export interface SpawnNdjsonOptions extends ProcessOptions {
|
|
|
20
39
|
* e.g. the agent prompt for `claude -p`. Avoids putting the prompt in argv.
|
|
21
40
|
*/
|
|
22
41
|
input?: string
|
|
42
|
+
/**
|
|
43
|
+
* Route the agent's stdout through an in-sandbox journal rather than holding
|
|
44
|
+
* its pipe directly.
|
|
45
|
+
*
|
|
46
|
+
* This is what makes a run survive host death: with nothing piped, there is
|
|
47
|
+
* no reader whose disappearance can SIGPIPE the agent, and a later host reads
|
|
48
|
+
* the same file from byte 0. Opt-in so every existing caller (and every
|
|
49
|
+
* existing test) is unaffected.
|
|
50
|
+
*/
|
|
51
|
+
journal?: JournalOptions
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Journaling configuration for {@link spawnNdjson}. */
|
|
55
|
+
export interface JournalOptions {
|
|
56
|
+
/** Run id the journal path is derived from. Must match across hosts. */
|
|
57
|
+
runId: string
|
|
58
|
+
/** Journal directory. Defaults to `/tmp/tanstack-runs`. */
|
|
59
|
+
dir?: string
|
|
60
|
+
/**
|
|
61
|
+
* Read an EXISTING journal instead of starting the agent. The read still
|
|
62
|
+
* begins at byte 0 — the alignment step, not the reader, decides what has
|
|
63
|
+
* already been delivered.
|
|
64
|
+
*/
|
|
65
|
+
attach?: boolean
|
|
66
|
+
/** Poll interval for providers that cannot follow. */
|
|
67
|
+
pollIntervalMs?: number
|
|
68
|
+
/**
|
|
69
|
+
* Run record store, consulted ONLY on an attach and only when the journal is
|
|
70
|
+
* absent, to tell "not written yet" from "will never be written" (see
|
|
71
|
+
* `attach-preflight.ts`). Optional so an attach with no store wired keeps the
|
|
72
|
+
* bounded wait while losing the unknown/terminal classification.
|
|
73
|
+
*/
|
|
74
|
+
runs?: RunStore
|
|
75
|
+
/**
|
|
76
|
+
* Bounded wait for a live run's journal to appear on an attach, AND — on every
|
|
77
|
+
* path, attach or fresh — the bound on the read's first byte
|
|
78
|
+
* (`ReadJournalOptions.firstByteTimeoutMs`). One knob for both because they
|
|
79
|
+
* bound the same question from two sides: the preflight covers "the file does
|
|
80
|
+
* not exist", the read covers "the file exists but nothing is writing to it",
|
|
81
|
+
* and a caller that widens one always means to widen the other.
|
|
82
|
+
*
|
|
83
|
+
* Defaults to `DEFAULT_ATTACH_JOURNAL_WAIT_MS`.
|
|
84
|
+
*/
|
|
85
|
+
attachWaitMs?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type JournaledOptions = SpawnNdjsonOptions & { journal: JournalOptions }
|
|
89
|
+
|
|
90
|
+
function isJournaled(options: SpawnNdjsonOptions): options is JournaledOptions {
|
|
91
|
+
return options.journal !== undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolvePaths(options: JournaledOptions) {
|
|
95
|
+
return journalPaths(options.journal.runId, options.journal.dir)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Strip the runner-only options, leaving what `handle.process.spawn` accepts.
|
|
100
|
+
*
|
|
101
|
+
* `signal` is deliberately KEPT: on the UNJOURNALED path the host holds the
|
|
102
|
+
* agent's stdout pipe, so a client disconnect should take the process down with
|
|
103
|
+
* it. The journaled path must not forward it — see
|
|
104
|
+
* {@link toJournaledSpawnOptions}.
|
|
105
|
+
*/
|
|
106
|
+
function toProcessOptions(options: SpawnNdjsonOptions): ProcessOptions {
|
|
107
|
+
const { onNonJsonLine, input, journal, ...rest } = options
|
|
108
|
+
void onNonJsonLine
|
|
109
|
+
void input
|
|
110
|
+
void journal
|
|
111
|
+
return rest
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The journaled agent's spawn options: {@link toProcessOptions} MINUS `signal`.
|
|
116
|
+
*
|
|
117
|
+
* The request's signal must never reach the agent process on this path. Providers
|
|
118
|
+
* DO act on it at spawn time — local-process registers it to `killTree` the
|
|
119
|
+
* process group, and daytona and docker honor it too — so forwarding it means a
|
|
120
|
+
* client disconnect kills the journaled agent. The agent then writes no exit
|
|
121
|
+
* sentinel, and a successor host takes over a run that is already dead: the exact
|
|
122
|
+
* opposite of the guarantee documented on {@link startJournaledAgent}, and of the
|
|
123
|
+
* reason the journal is a file rather than a pipe.
|
|
124
|
+
*
|
|
125
|
+
* The signal is still honored for the READ. `readJournalNdjson` forwards it to
|
|
126
|
+
* `readJournal` and `awaitAttachableJournal` on its own, so a disconnecting
|
|
127
|
+
* client stops tailing immediately. Only the agent spawn outlives the request.
|
|
128
|
+
*/
|
|
129
|
+
function toJournaledSpawnOptions(options: JournaledOptions): ProcessOptions {
|
|
130
|
+
const { signal, ...rest } = toProcessOptions(options)
|
|
131
|
+
void signal
|
|
132
|
+
return rest
|
|
23
133
|
}
|
|
24
134
|
|
|
25
135
|
/** Split a stream of arbitrary string chunks into complete lines. */
|
|
@@ -40,16 +150,250 @@ export async function* toLines(
|
|
|
40
150
|
if (buffer.length > 0) yield buffer
|
|
41
151
|
}
|
|
42
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Start the agent with its stdout (and the `{"__exit":N}` sentinel) redirected
|
|
155
|
+
* into the journal, then return.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately does NOT wait for the process and does NOT read its stdout: the
|
|
158
|
+
* whole point of journaling is that the host holds no handle on the agent's
|
|
159
|
+
* output, so a host that dies mid-run cannot take the agent down with it (no
|
|
160
|
+
* pipe to SIGPIPE). The spawned process is left running in the sandbox; the
|
|
161
|
+
* sentinel line the wrapper appends on exit is how anyone — this host or a
|
|
162
|
+
* successor — learns it finished. Stdin is still written directly to the
|
|
163
|
+
* spawned process, exactly as the unjournaled path does, since that transport
|
|
164
|
+
* is unaffected by where stdout goes.
|
|
165
|
+
*/
|
|
166
|
+
export async function startJournaledAgent(
|
|
167
|
+
handle: SandboxHandle,
|
|
168
|
+
command: string,
|
|
169
|
+
options: JournaledOptions,
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
const paths = resolvePaths(options)
|
|
172
|
+
const proc = await handle.process.spawn(
|
|
173
|
+
journaledCommand(command, paths),
|
|
174
|
+
toJournaledSpawnOptions(options),
|
|
175
|
+
)
|
|
176
|
+
if (options.input !== undefined) {
|
|
177
|
+
await proc.stdin.write(options.input)
|
|
178
|
+
await proc.stdin.end()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Chars of stderr attached to a non-zero-exit error, on both paths. */
|
|
183
|
+
const STDERR_ERROR_CHARS = 1000
|
|
184
|
+
|
|
185
|
+
async function* singleValue(value: string): AsyncIterable<string> {
|
|
186
|
+
yield value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Read the tail of a run's stderr sidecar, for the error message only.
|
|
191
|
+
*
|
|
192
|
+
* Returns `''` on ANY failure — a provider whose `exec` rejects, a sidecar that
|
|
193
|
+
* no longer exists, a base64 frame the provider truncated. The caller is on its
|
|
194
|
+
* way to throwing the real failure (the agent's non-zero exit), and losing a
|
|
195
|
+
* diagnostic suffix must never replace that error with a cleanup error. Decoding
|
|
196
|
+
* is deliberately lossy: `journalStderrReadCommand` reads the LAST N bytes, so
|
|
197
|
+
* byte 0 of the frame can sit mid-character.
|
|
198
|
+
*/
|
|
199
|
+
async function readStderrTail(
|
|
200
|
+
handle: SandboxHandle,
|
|
201
|
+
paths: JournalPaths,
|
|
202
|
+
): Promise<string> {
|
|
203
|
+
try {
|
|
204
|
+
const result = await handle.process.exec(journalStderrReadCommand(paths))
|
|
205
|
+
const decoder = new TextDecoder()
|
|
206
|
+
let text = ''
|
|
207
|
+
for await (const bytes of decodeBase64Stream(singleValue(result.stdout))) {
|
|
208
|
+
text += decoder.decode(bytes, { stream: true })
|
|
209
|
+
}
|
|
210
|
+
text += decoder.decode()
|
|
211
|
+
return text.trim()
|
|
212
|
+
} catch {
|
|
213
|
+
return ''
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Delete a terminal run's journal. Best effort by construction: see
|
|
219
|
+
* {@link journalCleanupCommand} for why a failure here cannot be allowed to fail
|
|
220
|
+
* a run that has already finished.
|
|
221
|
+
*/
|
|
222
|
+
async function cleanupJournal(
|
|
223
|
+
handle: SandboxHandle,
|
|
224
|
+
paths: JournalPaths,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
try {
|
|
227
|
+
await handle.process.exec(journalCleanupCommand(paths))
|
|
228
|
+
} catch {
|
|
229
|
+
// The sandbox may already be gone, `/tmp` may be read-only, the provider's
|
|
230
|
+
// `exec` may reject. Nothing about a completed run depends on the files
|
|
231
|
+
// still existing OR on them being gone, so there is nothing to report.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read a run's journal and yield each line parsed as JSON.
|
|
237
|
+
*
|
|
238
|
+
* Always reads from byte 0 — the alignment step (a later phase), not this
|
|
239
|
+
* reader, decides what a client has already seen. Stops at the `{"__exit":N}`
|
|
240
|
+
* sentinel, and throws for a non-zero N so the calling adapter's existing
|
|
241
|
+
* `catch` turns it into a `RUN_ERROR`, the same observable outcome the
|
|
242
|
+
* unjournaled path produces from a non-zero `wait()`. There is nothing to
|
|
243
|
+
* `wait()` on here: the host holds a `tail`, not the agent process, so the
|
|
244
|
+
* sentinel line IS the exit code.
|
|
245
|
+
*
|
|
246
|
+
* The sentinel is also what bounds journal growth: reaching it means the run is
|
|
247
|
+
* terminal, and a terminal run's record is the event log, so both journal files
|
|
248
|
+
* are deleted before this iterable finishes. The ordering below is load-bearing
|
|
249
|
+
* and is asserted, not merely commented:
|
|
250
|
+
*
|
|
251
|
+
* - The sentinel is captured and the loop is `break`-ed, so the source's
|
|
252
|
+
* `finally` kills the `tail` BEFORE the `rm` runs — the reader is stopped, then
|
|
253
|
+
* its input is deleted, never the other way round.
|
|
254
|
+
* - `exitCode` stays `undefined` if the journal stream ends without a sentinel.
|
|
255
|
+
* NOTHING is deleted on that path either way — the run may be mid-flight and a
|
|
256
|
+
* successor host may still need every byte — but the two causes are then
|
|
257
|
+
* separated by `options.signal.aborted`: an aborted consumer returns quietly,
|
|
258
|
+
* while a stream that died on its own (killed `tail`, destroyed sandbox, torn
|
|
259
|
+
* pipe) THROWS. Returning for both is how a truncated read used to reach the
|
|
260
|
+
* client as a normally-completing run.
|
|
261
|
+
* - A non-zero sentinel deletes too. The run is terminal either way.
|
|
262
|
+
* - The stderr sidecar is read BEFORE the deletion that destroys it, so a
|
|
263
|
+
* non-zero exit carries up to {@link STDERR_ERROR_CHARS} chars of the agent's
|
|
264
|
+
* own diagnostics, exactly as the unjournaled path below does. That closes the
|
|
265
|
+
* "Known regression" this function used to document; the read is bounded and
|
|
266
|
+
* failure-swallowing (see {@link readStderrTail}), so it cannot turn a run
|
|
267
|
+
* failure into a cleanup failure.
|
|
268
|
+
*
|
|
269
|
+
* On an ATTACH (`journal.attach === true`) the read is preceded by
|
|
270
|
+
* {@link awaitAttachableJournal}, which fails fast for a runId the store does not
|
|
271
|
+
* know or has already terminalized and otherwise waits a BOUNDED time for a live
|
|
272
|
+
* run's journal to appear. Without it, an attach to a runId with no journal
|
|
273
|
+
* created an empty one (`journalFollowCommand` does that deliberately) and tailed
|
|
274
|
+
* it forever — no sentinel, no error, no timeout.
|
|
275
|
+
*
|
|
276
|
+
* One case this does NOT bound: a run that reaches its sentinel while DETACHED
|
|
277
|
+
* has no host reading it, so nothing observes the sentinel and nothing here
|
|
278
|
+
* runs. Sweeping those is `pruneJournals`' job (`journal-sweep.ts`): it consults
|
|
279
|
+
* the run store's status for each journal it finds and deletes only the terminal
|
|
280
|
+
* ones, from a cron the application schedules rather than from a run.
|
|
281
|
+
*/
|
|
282
|
+
export async function* readJournalNdjson(
|
|
283
|
+
handle: SandboxHandle,
|
|
284
|
+
options: JournaledOptions,
|
|
285
|
+
): AsyncIterable<unknown> {
|
|
286
|
+
const paths = resolvePaths(options)
|
|
287
|
+
// ATTACH ONLY, and before the first read. `journalFollowCommand` CREATES the
|
|
288
|
+
// journal it tails, so an attach for a runId that never had one would
|
|
289
|
+
// otherwise create an empty file and tail it forever with no sentinel ever
|
|
290
|
+
// arriving. A fresh run must not be gated: its journal is created by its own
|
|
291
|
+
// `journaledCommand` spawn, which `spawnNdjson` has just issued.
|
|
292
|
+
if (options.journal.attach === true) {
|
|
293
|
+
await awaitAttachableJournal(handle, {
|
|
294
|
+
paths,
|
|
295
|
+
runId: options.journal.runId,
|
|
296
|
+
...(options.journal.runs === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { runs: options.journal.runs }),
|
|
299
|
+
...(options.journal.attachWaitMs === undefined
|
|
300
|
+
? {}
|
|
301
|
+
: { waitMs: options.journal.attachWaitMs }),
|
|
302
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
let exitCode: number | undefined
|
|
306
|
+
for await (const { line } of readJournal(handle, {
|
|
307
|
+
paths,
|
|
308
|
+
fromByte: 0,
|
|
309
|
+
runId: options.journal.runId,
|
|
310
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
311
|
+
...(options.journal.pollIntervalMs === undefined
|
|
312
|
+
? {}
|
|
313
|
+
: { pollIntervalMs: options.journal.pollIntervalMs }),
|
|
314
|
+
...(options.journal.attachWaitMs === undefined
|
|
315
|
+
? {}
|
|
316
|
+
: { firstByteTimeoutMs: options.journal.attachWaitMs }),
|
|
317
|
+
})) {
|
|
318
|
+
const trimmed = line.trim()
|
|
319
|
+
if (trimmed === '') continue
|
|
320
|
+
// The sentinel test comes FIRST and is nonce-checked, so an agent line that
|
|
321
|
+
// merely looks like a sentinel is delivered as the event it is instead of
|
|
322
|
+
// truncating the run (see `parseExitSentinel`).
|
|
323
|
+
const sentinel = parseExitSentinel(trimmed, paths)
|
|
324
|
+
if (sentinel !== null) {
|
|
325
|
+
exitCode = sentinel
|
|
326
|
+
break
|
|
327
|
+
}
|
|
328
|
+
let parsed: unknown
|
|
329
|
+
try {
|
|
330
|
+
parsed = JSON.parse(trimmed)
|
|
331
|
+
} catch {
|
|
332
|
+
options.onNonJsonLine?.(trimmed)
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
yield parsed
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The stream ended with no sentinel. Two very different causes share this
|
|
339
|
+
// shape, and collapsing them is how a torn-down read used to be recorded as a
|
|
340
|
+
// short but SUCCESSFUL run:
|
|
341
|
+
//
|
|
342
|
+
// - The CONSUMER aborted (lease lost, client gone, host shutting down). Not a
|
|
343
|
+
// failure and not terminal: return, having deleted nothing, because a
|
|
344
|
+
// successor host may still need every byte. `pipeToRunLog`'s post-loop check
|
|
345
|
+
// turns this into `'aborted'`.
|
|
346
|
+
// - The read DIED (the `tail` was killed, the sandbox was destroyed, the pipe
|
|
347
|
+
// was torn down). The iterable ends without an error, so returning here made
|
|
348
|
+
// the adapter emit a normally-completing but silently TRUNCATED run — in a
|
|
349
|
+
// function whose documented job is to throw so the adapter converts it to a
|
|
350
|
+
// `RUN_ERROR`. `signal.aborted` is what tells the two apart, and the throw
|
|
351
|
+
// matches the shape the unjournaled path already uses for a bad exit.
|
|
352
|
+
if (exitCode === undefined) {
|
|
353
|
+
if (options.signal?.aborted === true) return
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Agent journal stream for run ${options.journal.runId} ended without an exit sentinel ` +
|
|
356
|
+
`(${paths.journal}). The run was NOT observed to finish: the tail was torn down, ` +
|
|
357
|
+
`the sandbox went away, or the agent's shell died before writing its sentinel. ` +
|
|
358
|
+
`Both journal files are left in place for a successor host.`,
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const stderr = exitCode === 0 ? '' : await readStderrTail(handle, paths)
|
|
363
|
+
await cleanupJournal(handle, paths)
|
|
364
|
+
if (exitCode !== 0) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Agent process exited with code ${exitCode}` +
|
|
367
|
+
(stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''),
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
43
372
|
/**
|
|
44
373
|
* Spawn `command` in the sandbox and yield each stdout line parsed as JSON.
|
|
45
|
-
*
|
|
46
|
-
*
|
|
374
|
+
*
|
|
375
|
+
* Without `options.journal`, behavior is byte-identical to before: resolves
|
|
376
|
+
* the spawn handle's exit via `wait()` after stdout closes; a non-zero exit
|
|
377
|
+
* with no events surfaced is the adapter's concern to detect.
|
|
378
|
+
*
|
|
379
|
+
* With `options.journal`, the agent's stdout is redirected into an in-sandbox
|
|
380
|
+
* journal (unless `journal.attach` is set, meaning a run already in flight)
|
|
381
|
+
* and then read back from byte 0 — one code path for a fresh run and an
|
|
382
|
+
* attach, both going through {@link readJournalNdjson}.
|
|
47
383
|
*/
|
|
48
384
|
export async function* spawnNdjson(
|
|
49
385
|
handle: SandboxHandle,
|
|
50
386
|
command: string,
|
|
51
387
|
options: SpawnNdjsonOptions = {},
|
|
52
388
|
): AsyncIterable<unknown> {
|
|
389
|
+
if (isJournaled(options)) {
|
|
390
|
+
if (options.journal.attach !== true) {
|
|
391
|
+
await startJournaledAgent(handle, command, options)
|
|
392
|
+
}
|
|
393
|
+
yield* readJournalNdjson(handle, options)
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
|
|
53
397
|
const { onNonJsonLine, input, ...processOptions } = options
|
|
54
398
|
const proc = await handle.process.spawn(command, processOptions)
|
|
55
399
|
|
|
@@ -93,7 +437,7 @@ export async function* spawnNdjson(
|
|
|
93
437
|
const stderr = stderrChunks.join('').trim()
|
|
94
438
|
throw new Error(
|
|
95
439
|
`Agent process exited with code ${exitCode}` +
|
|
96
|
-
(stderr ? `: ${stderr.slice(0,
|
|
440
|
+
(stderr ? `: ${stderr.slice(0, STDERR_ERROR_CHARS)}` : ''),
|
|
97
441
|
)
|
|
98
442
|
}
|
|
99
443
|
}
|
package/src/sandbox.ts
CHANGED
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
import { bootstrapWorkspace } from './bootstrap'
|
|
10
10
|
import { resolveAllSecrets } from './secrets'
|
|
11
11
|
import { computeSandboxKey } from './key'
|
|
12
|
-
import { InMemoryLockStore
|
|
12
|
+
import { InMemoryLockStore } from '@tanstack/ai/locks'
|
|
13
|
+
import type { LockStore } from '@tanstack/ai/locks'
|
|
13
14
|
import type { SandboxFileHookEvent } from '@tanstack/ai'
|
|
15
|
+
import { InMemorySandboxInstanceStore } from './instance-store'
|
|
16
|
+
import type { SandboxInstanceStore } from './instance-store'
|
|
14
17
|
import type { SandboxHandle, SandboxProvider } from './contracts'
|
|
15
18
|
import type { SandboxKeyInput } from './key'
|
|
16
|
-
import type { LockStore, SandboxStore } from './store'
|
|
17
19
|
import type { SandboxPolicy } from './policy'
|
|
18
20
|
import type { WorkspaceDefinition } from './workspace'
|
|
19
21
|
|
|
@@ -69,7 +71,7 @@ export interface SandboxEnsureContext {
|
|
|
69
71
|
threadId: string
|
|
70
72
|
runId: string
|
|
71
73
|
/** Persistence seam; falls back to an in-memory store when absent. */
|
|
72
|
-
store?:
|
|
74
|
+
store?: SandboxInstanceStore
|
|
73
75
|
/** Lock seam; falls back to an in-memory lock when absent. */
|
|
74
76
|
locks?: LockStore
|
|
75
77
|
tenant?: { userId?: string; orgId?: string }
|
|
@@ -109,9 +111,16 @@ function parseMaxAgeMs(value: string | undefined): number | undefined {
|
|
|
109
111
|
return undefined
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Bound for the unfenced teardown `destroy` call (see `destroy` below). Long
|
|
116
|
+
* enough that a slow provider API still completes, short enough that a wedged
|
|
117
|
+
* one cannot pin the process forever.
|
|
118
|
+
*/
|
|
119
|
+
const DESTROY_TIMEOUT_MS = 60 * 1000
|
|
120
|
+
|
|
112
121
|
// Process-lifetime fallbacks shared across all definitions so concurrent
|
|
113
122
|
// ensures for the same key serialize even without an injected store/lock.
|
|
114
|
-
const fallbackStore = new
|
|
123
|
+
const fallbackStore = new InMemorySandboxInstanceStore()
|
|
115
124
|
const fallbackLocks = new InMemoryLockStore()
|
|
116
125
|
|
|
117
126
|
export function defineSandbox(config: SandboxConfig): SandboxDefinition {
|
|
@@ -242,10 +251,31 @@ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
|
|
|
242
251
|
const key = computeSandboxKey(keyInputFor(ctx))
|
|
243
252
|
const existing = await store.get(key)
|
|
244
253
|
if (!existing) return
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
254
|
+
/*
|
|
255
|
+
* TEARDOWN IS DELIBERATELY NOT FENCED BY `ctx.signal`.
|
|
256
|
+
*
|
|
257
|
+
* `destroy` runs on every teardown path INCLUDING the one caused by that
|
|
258
|
+
* very signal aborting, so forwarding it hands the provider a signal that is
|
|
259
|
+
* already aborted: a provider that honors it does nothing and returns
|
|
260
|
+
* successfully, and `store.delete` below then removes the only pointer to a
|
|
261
|
+
* live, billed sandbox. `SandboxInstanceStore` has no `list` (see the note
|
|
262
|
+
* at the top of `reclaim.ts`), so that sandbox is unreachable from then on.
|
|
263
|
+
*
|
|
264
|
+
* Same reasoning as `close()` never being fenced by the run claim (see
|
|
265
|
+
* `fenceDurability` in `claim.ts`): cleanup must outlive whatever cancelled
|
|
266
|
+
* the work. A fresh controller with its own bounded timeout keeps the call
|
|
267
|
+
* from hanging forever without letting the caller's abort cancel it.
|
|
268
|
+
*/
|
|
269
|
+
const teardown = new AbortController()
|
|
270
|
+
const timer = setTimeout(() => teardown.abort(), DESTROY_TIMEOUT_MS)
|
|
271
|
+
try {
|
|
272
|
+
await config.provider.destroy({
|
|
273
|
+
id: existing.providerSandboxId,
|
|
274
|
+
signal: teardown.signal,
|
|
275
|
+
})
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(timer)
|
|
278
|
+
}
|
|
249
279
|
await store.delete(key)
|
|
250
280
|
}
|
|
251
281
|
|
package/src/shell.ts
CHANGED
|
@@ -59,8 +59,23 @@ export interface BootstrapShell {
|
|
|
59
59
|
export interface BootstrapShellOptions {
|
|
60
60
|
/** Working directory to start the shell in (passed as ProcessOptions.cwd). */
|
|
61
61
|
cwd?: string
|
|
62
|
+
/**
|
|
63
|
+
* Belt-and-braces deadline for a single `run()` to see its sentinel. The
|
|
64
|
+
* primary termination condition is the stdout stream ending (see
|
|
65
|
+
* {@link createBootstrapShell}); this only catches a shell that is alive,
|
|
66
|
+
* silent, and never going to answer. Generous by default because setup steps
|
|
67
|
+
* legitimately run for a long time (`npm install`, image pulls).
|
|
68
|
+
*/
|
|
69
|
+
commandTimeoutMs?: number
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
/** Default {@link BootstrapShellOptions.commandTimeoutMs} — 30 minutes. */
|
|
73
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60 * 1000
|
|
74
|
+
|
|
75
|
+
/** Race marker for the per-command deadline. A symbol cannot collide with a
|
|
76
|
+
* literal stdout line (a line of text `'timeout'` would). */
|
|
77
|
+
const TIMED_OUT = Symbol('bootstrap-shell-timeout')
|
|
78
|
+
|
|
64
79
|
/**
|
|
65
80
|
* Spawn one `sh` process and return a {@link BootstrapShell} that drives it
|
|
66
81
|
* via the sentinel-echo protocol.
|
|
@@ -87,18 +102,35 @@ export async function createBootstrapShell(
|
|
|
87
102
|
* the iterator open. Buffer chunks into lines manually.
|
|
88
103
|
*/
|
|
89
104
|
const lineBuffer: Array<string> = []
|
|
90
|
-
|
|
105
|
+
// `null` means "the stdout stream ended" — distinct from an empty line, which
|
|
106
|
+
// `sh` emits constantly. Collapsing the two is what let a dead shell feed an
|
|
107
|
+
// infinite supply of `''` into a sentinel-hunting loop.
|
|
108
|
+
let pending: Array<(line: string | null) => void> = []
|
|
91
109
|
let streamDone = false
|
|
110
|
+
let streamError: unknown
|
|
92
111
|
|
|
93
112
|
/** Feed the stdout async-iterable into the shared line queue. */
|
|
94
113
|
async function drainStdout(): Promise<void> {
|
|
95
114
|
let partial = ''
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
115
|
+
try {
|
|
116
|
+
for await (const chunk of proc.stdout) {
|
|
117
|
+
partial += chunk
|
|
118
|
+
const parts = partial.split('\n')
|
|
119
|
+
// All but the last element are complete lines.
|
|
120
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
121
|
+
const line = parts[i] as string
|
|
122
|
+
const resolver = pending.shift()
|
|
123
|
+
if (resolver !== undefined) {
|
|
124
|
+
resolver(line)
|
|
125
|
+
} else {
|
|
126
|
+
lineBuffer.push(line)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
partial = parts[parts.length - 1] as string
|
|
130
|
+
}
|
|
131
|
+
// Flush any trailing partial line.
|
|
132
|
+
if (partial.length > 0) {
|
|
133
|
+
const line = partial
|
|
102
134
|
const resolver = pending.shift()
|
|
103
135
|
if (resolver !== undefined) {
|
|
104
136
|
resolver(line)
|
|
@@ -106,39 +138,40 @@ export async function createBootstrapShell(
|
|
|
106
138
|
lineBuffer.push(line)
|
|
107
139
|
}
|
|
108
140
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// A throw while iterating stdout (transport reset, provider stream error)
|
|
143
|
+
// must NOT leave waiters parked on a promise nobody resolves. Record it so
|
|
144
|
+
// `run()` can name the cause, and fall through to the `finally` that
|
|
145
|
+
// unblocks everyone.
|
|
146
|
+
streamError = error
|
|
147
|
+
} finally {
|
|
148
|
+
streamDone = true
|
|
149
|
+
// Unblock any remaining waiters with the end-of-stream marker.
|
|
150
|
+
for (const resolver of pending) {
|
|
151
|
+
resolver(null)
|
|
119
152
|
}
|
|
153
|
+
pending = []
|
|
120
154
|
}
|
|
121
|
-
streamDone = true
|
|
122
|
-
// Resolve any remaining waiters with an empty sentinel so they unblock.
|
|
123
|
-
for (const resolver of pending) {
|
|
124
|
-
resolver('')
|
|
125
|
-
}
|
|
126
|
-
pending = []
|
|
127
155
|
}
|
|
128
156
|
|
|
129
|
-
|
|
157
|
+
/*
|
|
158
|
+
* Start draining immediately; do NOT await — runs concurrently. The `try/catch`
|
|
159
|
+
* inside `drainStdout` means this promise never rejects, so there is no
|
|
160
|
+
* unhandled rejection while nothing is awaiting it, and `dispose()` can await
|
|
161
|
+
* it unconditionally.
|
|
162
|
+
*/
|
|
130
163
|
const drainPromise = drainStdout()
|
|
131
164
|
|
|
132
|
-
/** Read the next line from the shared queue. */
|
|
133
|
-
function nextLine(): Promise<string> {
|
|
165
|
+
/** Read the next line from the shared queue, or `null` once stdout ended. */
|
|
166
|
+
function nextLine(): Promise<string | null> {
|
|
134
167
|
const buffered = lineBuffer.shift()
|
|
135
168
|
if (buffered !== undefined) {
|
|
136
169
|
return Promise.resolve(buffered)
|
|
137
170
|
}
|
|
138
171
|
if (streamDone) {
|
|
139
|
-
return Promise.resolve(
|
|
172
|
+
return Promise.resolve(null)
|
|
140
173
|
}
|
|
141
|
-
return new Promise<string>((resolve) => {
|
|
174
|
+
return new Promise<string | null>((resolve) => {
|
|
142
175
|
pending.push(resolve)
|
|
143
176
|
})
|
|
144
177
|
}
|
|
@@ -162,18 +195,53 @@ export async function createBootstrapShell(
|
|
|
162
195
|
|
|
163
196
|
const outputLines: Array<string> = []
|
|
164
197
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
198
|
+
/*
|
|
199
|
+
* Read lines until we find the sentinel — but the wait MUST be able to end
|
|
200
|
+
* without one. `sh` can exit before it ever prints the sentinel (a missing
|
|
201
|
+
* binary, an OOM kill, the provider reaping the sandbox mid-bootstrap), and
|
|
202
|
+
* a loop whose only exit is the sentinel then spins on end-of-stream
|
|
203
|
+
* forever, pushing into `outputLines` until the host process dies of memory
|
|
204
|
+
* exhaustion. Two independent terminators:
|
|
205
|
+
* 1. `nextLine()` yields `null` the moment stdout is done — the real fix,
|
|
206
|
+
* it fires as soon as the shell is gone.
|
|
207
|
+
* 2. A deadline, for a shell that stays alive and simply never answers.
|
|
208
|
+
*/
|
|
209
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
210
|
+
const deadline = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
211
|
+
timer = setTimeout(
|
|
212
|
+
() => resolve(TIMED_OUT),
|
|
213
|
+
opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
for (;;) {
|
|
219
|
+
const line = await Promise.race([nextLine(), deadline])
|
|
220
|
+
if (line === TIMED_OUT) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`bootstrap shell: timed out after ${
|
|
223
|
+
opts.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
224
|
+
}ms waiting for the sentinel of command: ${command}`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
if (line === null) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`bootstrap shell: the shell exited before the sentinel was printed; command: ${command}`,
|
|
230
|
+
streamError === undefined ? undefined : { cause: streamError },
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
if (line.startsWith(`${sentinel} `)) {
|
|
234
|
+
const codeStr = line.slice(sentinel.length + 1).trim()
|
|
235
|
+
const exitCode = parseInt(codeStr, 10)
|
|
236
|
+
return {
|
|
237
|
+
exitCode: Number.isFinite(exitCode) ? exitCode : 1,
|
|
238
|
+
stdout: outputLines.join('\n'),
|
|
239
|
+
}
|
|
174
240
|
}
|
|
241
|
+
outputLines.push(line)
|
|
175
242
|
}
|
|
176
|
-
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timer)
|
|
177
245
|
}
|
|
178
246
|
}
|
|
179
247
|
|