@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
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a run's journal, live or after the fact, on one code path.
|
|
3
|
+
*
|
|
4
|
+
* Resume is not a special case: every read is `tail -c +N` for some N, and a
|
|
5
|
+
* fresh run is simply N = 0. That is deliberate — `pid` is `-1` on five of six
|
|
6
|
+
* providers, so re-attaching to an existing reader is impossible and a resumed
|
|
7
|
+
* read always spawns a new `tail` anyway.
|
|
8
|
+
*
|
|
9
|
+
* Two strategies, chosen by capability rather than by provider name:
|
|
10
|
+
*
|
|
11
|
+
* - **follow** (`spawn` + `tail -f`): the default. Streams with no polling cost
|
|
12
|
+
* and is killed when the consumer stops. Its command pipes into nothing — see
|
|
13
|
+
* `journal.ts` rule 2 — so this path re-encodes the provider's decoded text
|
|
14
|
+
* rather than decoding a base64 frame.
|
|
15
|
+
* - **poll** (bounded `exec`, no `-f`): for a provider whose spawned process
|
|
16
|
+
* cannot be stopped. Cloudflare's `kill()` is a documented no-op and it
|
|
17
|
+
* forwards the AbortSignal to neither `exec` nor `spawn`, so a `tail -f`
|
|
18
|
+
* there would run forever inside the container. Every poll command terminates
|
|
19
|
+
* on its own, so nothing needs killing.
|
|
20
|
+
*
|
|
21
|
+
* **Neither strategy may wait forever for its FIRST byte.** This is the bound
|
|
22
|
+
* that used to be missing, and its absence was reachable three ways, one of them
|
|
23
|
+
* self-inflicted:
|
|
24
|
+
*
|
|
25
|
+
* 1. `journalFollowCommand` CREATES the journal before tailing it (`: >> file`),
|
|
26
|
+
* which it must, so a read for a runId whose journal never existed
|
|
27
|
+
* manufactures an empty file and tails it forever. The attach preflight
|
|
28
|
+
* (`attach-preflight.ts`) catches most of those, but it is wired at exactly
|
|
29
|
+
* one call site and only for `attach === true` — the exported
|
|
30
|
+
* {@link readJournal} that `docs/sandbox/journal.md` tells users to write has
|
|
31
|
+
* no preflight, no store, and no runId to look one up with.
|
|
32
|
+
* 2. The preflight's own probe can be unusable, and it deliberately falls through
|
|
33
|
+
* to a bounded wait rather than skipping; a journal that exists but is
|
|
34
|
+
* abandoned still reaches the reader.
|
|
35
|
+
* 3. SIGKILL/OOM of the agent's shell between its last line and its sentinel
|
|
36
|
+
* `printf` leaves a real, non-empty, permanently-silent journal.
|
|
37
|
+
*
|
|
38
|
+
* So a read that receives NO bytes within {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS}
|
|
39
|
+
* raises {@link JournalAttachUnavailableError} with reason `'journal-stalled'`
|
|
40
|
+
* instead of parking. The bound is on the FIRST byte only, deliberately: once the
|
|
41
|
+
* journal is producing, how long the agent thinks between lines is the agent's
|
|
42
|
+
* business and no deadline here may cut a healthy run short. A consumer abort is
|
|
43
|
+
* not a stall — it ends the read quietly, as it always did.
|
|
44
|
+
*/
|
|
45
|
+
import {
|
|
46
|
+
DEFAULT_ATTACH_JOURNAL_WAIT_MS,
|
|
47
|
+
JournalAttachUnavailableError,
|
|
48
|
+
} from './attach-preflight'
|
|
49
|
+
import { journalFollowCommand, journalReadCommand } from './journal'
|
|
50
|
+
import {
|
|
51
|
+
decodeBase64Stream,
|
|
52
|
+
encodeUtf8Stream,
|
|
53
|
+
toJournalLines,
|
|
54
|
+
} from './journal-bytes'
|
|
55
|
+
import type { JournalPaths } from './journal'
|
|
56
|
+
import type { JournalLine } from './journal-bytes'
|
|
57
|
+
import type { ProcessOptions, SandboxHandle } from './contracts'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Poll interval for the bounded-`exec` strategy. Matches the interval
|
|
61
|
+
* `ai-sandbox-cloudflare`'s run-log Durable Object already uses, so the two
|
|
62
|
+
* readers have the same latency profile.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_JOURNAL_POLL_MS = 250
|
|
65
|
+
|
|
66
|
+
export interface ReadJournalOptions {
|
|
67
|
+
paths: JournalPaths
|
|
68
|
+
/**
|
|
69
|
+
* Count of journal bytes already consumed. The read starts at the next byte.
|
|
70
|
+
* Defaults to 0, which is also what a takeover uses: the alignment step, not
|
|
71
|
+
* the reader, decides what has already been delivered.
|
|
72
|
+
*/
|
|
73
|
+
fromByte?: number
|
|
74
|
+
/** Stop reading. On the follow strategy this also kills the `tail`. */
|
|
75
|
+
signal?: AbortSignal
|
|
76
|
+
/** Override the capability-derived strategy. Tests and diagnostics only. */
|
|
77
|
+
strategy?: 'follow' | 'poll'
|
|
78
|
+
/** Poll strategy only. Defaults to {@link DEFAULT_JOURNAL_POLL_MS}. */
|
|
79
|
+
pollIntervalMs?: number
|
|
80
|
+
/** Working directory for the read command. Paths are absolute, so rarely needed. */
|
|
81
|
+
cwd?: string
|
|
82
|
+
/**
|
|
83
|
+
* How long to wait for the FIRST byte of the journal before failing with
|
|
84
|
+
* `'journal-stalled'`. Defaults to {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} — the
|
|
85
|
+
* same number that bounds the attach preflight, because it bounds the same
|
|
86
|
+
* question from the other side. `0` or a non-finite value disables the bound;
|
|
87
|
+
* do that only where some OTHER deadline already covers the read, since an
|
|
88
|
+
* unbounded read of an empty journal never returns.
|
|
89
|
+
*
|
|
90
|
+
* Only the first byte is bounded. An agent that streams slowly is never cut
|
|
91
|
+
* off.
|
|
92
|
+
*/
|
|
93
|
+
firstByteTimeoutMs?: number
|
|
94
|
+
/**
|
|
95
|
+
* Run id, for the stall error's message only. Defaults to naming the journal
|
|
96
|
+
* path, which is always available and always identifies the run uniquely.
|
|
97
|
+
*/
|
|
98
|
+
runId?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Which read strategy a provider supports.
|
|
103
|
+
*
|
|
104
|
+
* Keyed on capabilities, never on `handle.provider`: a BYO provider with the
|
|
105
|
+
* same limitation must get the same treatment, and name-sniffing would silently
|
|
106
|
+
* hand it an unstoppable `tail -f`.
|
|
107
|
+
*/
|
|
108
|
+
export function journalReadStrategy(handle: SandboxHandle): 'follow' | 'poll' {
|
|
109
|
+
const { backgroundProcesses, killableProcesses } = handle.capabilities
|
|
110
|
+
return backgroundProcesses && killableProcesses ? 'follow' : 'poll'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function processOptions(options: ReadJournalOptions): ProcessOptions {
|
|
114
|
+
return {
|
|
115
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
116
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Resolution of the abort race in {@link untilAborted}. Never a stream value. */
|
|
121
|
+
const ABORTED = Symbol('journal-read-aborted')
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Iterate `source` but stop the moment `signal` fires, instead of waiting for
|
|
125
|
+
* the stream to close.
|
|
126
|
+
*
|
|
127
|
+
* Without this, aborting a follow read only *asks* the provider to kill `tail`
|
|
128
|
+
* and then blocks on `stdout` until that kill closes the pipe — which is not a
|
|
129
|
+
* guarantee any provider makes. On local-process/Windows, `killTree` falls back
|
|
130
|
+
* to signalling only the `sh` wrapper if `taskkill` is unavailable, leaving the
|
|
131
|
+
* `tail` grandchild holding the stdout pipe open, and the read rides past its
|
|
132
|
+
* own AbortSignal until some outer timeout fires. The signal is the caller's
|
|
133
|
+
* contract with the reader, so the reader honors it itself and treats the kill
|
|
134
|
+
* as best-effort cleanup. (local-process now also verifies the tree is gone and
|
|
135
|
+
* sweeps the MSYS grandchildren `taskkill /T` cannot reach, but that is a
|
|
136
|
+
* provider improving its best effort — not a guarantee this reader may assume of
|
|
137
|
+
* any provider.)
|
|
138
|
+
*/
|
|
139
|
+
async function* untilAborted<T>(
|
|
140
|
+
source: AsyncIterable<T>,
|
|
141
|
+
signal: AbortSignal | undefined,
|
|
142
|
+
): AsyncIterable<T> {
|
|
143
|
+
if (!signal) {
|
|
144
|
+
yield* source
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (signal.aborted) return
|
|
148
|
+
let onAbort: (() => void) | undefined
|
|
149
|
+
const aborted = new Promise<typeof ABORTED>((resolve) => {
|
|
150
|
+
onAbort = () => resolve(ABORTED)
|
|
151
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
152
|
+
})
|
|
153
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
154
|
+
try {
|
|
155
|
+
for (;;) {
|
|
156
|
+
const next = await Promise.race([iterator.next(), aborted])
|
|
157
|
+
if (next === ABORTED || next.done === true) return
|
|
158
|
+
yield next.value
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
if (onAbort) signal.removeEventListener('abort', onAbort)
|
|
162
|
+
// NOT awaited. On an async generator, `return()` queues behind the pending
|
|
163
|
+
// `next()` we just abandoned, so awaiting it would block for exactly as
|
|
164
|
+
// long as the stream we gave up waiting for — reintroducing the hang this
|
|
165
|
+
// helper exists to remove. The rejection is swallowed for the same reason
|
|
166
|
+
// `kill` is best-effort below: the source may already be gone.
|
|
167
|
+
void iterator.return?.().catch(() => {})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolution of the first-byte race in {@link withFirstByteDeadline}. */
|
|
172
|
+
const STALLED = Symbol('journal-read-stalled')
|
|
173
|
+
|
|
174
|
+
/** The bound in effect for a read; `undefined` when the caller disabled it. */
|
|
175
|
+
function firstByteTimeout(options: ReadJournalOptions): number | undefined {
|
|
176
|
+
const ms = options.firstByteTimeoutMs ?? DEFAULT_ATTACH_JOURNAL_WAIT_MS
|
|
177
|
+
return Number.isFinite(ms) && ms > 0 ? ms : undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The `'journal-stalled'` failure, shared by both strategies so the two report
|
|
182
|
+
* the same diagnosis for the same state.
|
|
183
|
+
*/
|
|
184
|
+
function stalled(
|
|
185
|
+
options: ReadJournalOptions,
|
|
186
|
+
timeoutMs: number,
|
|
187
|
+
): JournalAttachUnavailableError {
|
|
188
|
+
return new JournalAttachUnavailableError(
|
|
189
|
+
options.runId ?? options.paths.journal,
|
|
190
|
+
'journal-stalled',
|
|
191
|
+
`its journal (${options.paths.journal}) delivered no bytes within ${timeoutMs}ms. ` +
|
|
192
|
+
`The file exists but nothing is appending to it and no '__exit' sentinel can arrive, ` +
|
|
193
|
+
`so following it would never return: either the read created it itself (a runId with no journal), ` +
|
|
194
|
+
`or the agent's shell was killed before it could write its sentinel.`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pass `source` through unchanged, except that receiving NO value within
|
|
200
|
+
* `timeoutMs` throws.
|
|
201
|
+
*
|
|
202
|
+
* Only the first value is raced. After it, the source is iterated directly, so a
|
|
203
|
+
* long gap between later values costs nothing and cannot fail a healthy read.
|
|
204
|
+
*
|
|
205
|
+
* A source that simply ENDS before the deadline is not a stall — that is the
|
|
206
|
+
* consumer's abort (`untilAborted` returns on abort) or a `tail` that exited —
|
|
207
|
+
* and it returns quietly, preserving the "an abort diagnoses nothing" rule.
|
|
208
|
+
*/
|
|
209
|
+
async function* withFirstByteDeadline<T>(
|
|
210
|
+
source: AsyncIterable<T>,
|
|
211
|
+
timeoutMs: number | undefined,
|
|
212
|
+
onStall: () => JournalAttachUnavailableError,
|
|
213
|
+
): AsyncIterable<T> {
|
|
214
|
+
if (timeoutMs === undefined) {
|
|
215
|
+
yield* source
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
219
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
220
|
+
const expired = new Promise<typeof STALLED>((resolve) => {
|
|
221
|
+
timer = setTimeout(() => resolve(STALLED), timeoutMs)
|
|
222
|
+
})
|
|
223
|
+
try {
|
|
224
|
+
const first = await Promise.race([iterator.next(), expired])
|
|
225
|
+
if (first === STALLED) throw onStall()
|
|
226
|
+
if (first.done === true) return
|
|
227
|
+
yield first.value
|
|
228
|
+
for (;;) {
|
|
229
|
+
const next = await iterator.next()
|
|
230
|
+
if (next.done === true) return
|
|
231
|
+
yield next.value
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer)
|
|
235
|
+
// NOT awaited, for the reason `untilAborted` documents: on the stall path the
|
|
236
|
+
// abandoned `next()` is exactly the promise that never settles, so awaiting
|
|
237
|
+
// the `return()` queued behind it would reinstate the hang being reported.
|
|
238
|
+
void iterator.return?.().catch(() => {})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function* followJournal(
|
|
243
|
+
handle: SandboxHandle,
|
|
244
|
+
options: ReadJournalOptions,
|
|
245
|
+
): AsyncIterable<JournalLine> {
|
|
246
|
+
const fromByte = options.fromByte ?? 0
|
|
247
|
+
const proc = await handle.process.spawn(
|
|
248
|
+
journalFollowCommand(options.paths, fromByte),
|
|
249
|
+
processOptions(options),
|
|
250
|
+
)
|
|
251
|
+
const timeoutMs = firstByteTimeout(options)
|
|
252
|
+
try {
|
|
253
|
+
yield* toJournalLines(
|
|
254
|
+
encodeUtf8Stream(
|
|
255
|
+
withFirstByteDeadline(
|
|
256
|
+
untilAborted(proc.stdout, options.signal),
|
|
257
|
+
timeoutMs,
|
|
258
|
+
// Narrowed by `withFirstByteDeadline` only calling this when the bound
|
|
259
|
+
// is in effect; `?? 0` keeps that provable without an assertion.
|
|
260
|
+
() => stalled(options, timeoutMs ?? 0),
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
fromByte,
|
|
264
|
+
)
|
|
265
|
+
} finally {
|
|
266
|
+
// The consumer may stop early (client gone, lease lost). Providers whose
|
|
267
|
+
// `kill` is real stop the `tail` here; the signal covers the rest. Guarded
|
|
268
|
+
// because a `finally` that throws would replace the consumer's own reason
|
|
269
|
+
// for stopping.
|
|
270
|
+
try {
|
|
271
|
+
await proc.kill()
|
|
272
|
+
} catch {
|
|
273
|
+
// Best effort: the process may already be gone.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
279
|
+
if (ms <= 0) return Promise.resolve()
|
|
280
|
+
return new Promise<void>((resolve) => {
|
|
281
|
+
const timer = setTimeout(finish, ms)
|
|
282
|
+
function finish(): void {
|
|
283
|
+
clearTimeout(timer)
|
|
284
|
+
signal?.removeEventListener('abort', finish)
|
|
285
|
+
resolve()
|
|
286
|
+
}
|
|
287
|
+
signal?.addEventListener('abort', finish, { once: true })
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function* singleValue(value: string): AsyncIterable<string> {
|
|
292
|
+
yield value
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function* pollJournal(
|
|
296
|
+
handle: SandboxHandle,
|
|
297
|
+
options: ReadJournalOptions,
|
|
298
|
+
): AsyncIterable<JournalLine> {
|
|
299
|
+
const intervalMs = options.pollIntervalMs ?? DEFAULT_JOURNAL_POLL_MS
|
|
300
|
+
const timeoutMs = firstByteTimeout(options)
|
|
301
|
+
// Same bound as the follow path, expressed the way a polling loop can enforce
|
|
302
|
+
// it: an empty frame every time until the deadline is a stalled journal, and
|
|
303
|
+
// parking here forever is the same defect from the other strategy.
|
|
304
|
+
const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs
|
|
305
|
+
let sawBytes = false
|
|
306
|
+
let position = options.fromByte ?? 0
|
|
307
|
+
while (!options.signal?.aborted) {
|
|
308
|
+
const result = await handle.process.exec(
|
|
309
|
+
journalReadCommand(options.paths, position),
|
|
310
|
+
processOptions(options),
|
|
311
|
+
)
|
|
312
|
+
if (result.stdout.trim() !== '') sawBytes = true
|
|
313
|
+
if (
|
|
314
|
+
!sawBytes &&
|
|
315
|
+
deadline !== undefined &&
|
|
316
|
+
timeoutMs !== undefined &&
|
|
317
|
+
Date.now() >= deadline
|
|
318
|
+
) {
|
|
319
|
+
throw stalled(options, timeoutMs)
|
|
320
|
+
}
|
|
321
|
+
// Each poll re-reads from `position`, so a line left incomplete by the
|
|
322
|
+
// previous poll is simply re-fetched whole. That is why `position` advances
|
|
323
|
+
// only on a COMPLETE line: advancing on bytes received would strand a
|
|
324
|
+
// partial line's prefix and corrupt every following line.
|
|
325
|
+
for await (const line of toJournalLines(
|
|
326
|
+
decodeBase64Stream(singleValue(result.stdout)),
|
|
327
|
+
position,
|
|
328
|
+
)) {
|
|
329
|
+
yield line
|
|
330
|
+
position = line.endPosition
|
|
331
|
+
}
|
|
332
|
+
if (options.signal?.aborted) return
|
|
333
|
+
await sleep(intervalMs, options.signal)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Read a run's journal as positioned lines.
|
|
339
|
+
*
|
|
340
|
+
* **This is a public entry point and it CANNOT hang.** It has no `RunStore` in
|
|
341
|
+
* its signature and no runId to look one up with, so it cannot run the
|
|
342
|
+
* `attach-preflight.ts` gate that classifies a stale or mistyped runId as
|
|
343
|
+
* `'unknown-run'`/`'terminal-run'`; what it has instead is the unconditional
|
|
344
|
+
* bound described in the module doc. A runId with no journal therefore fails with
|
|
345
|
+
* {@link JournalAttachUnavailableError} (`reason: 'journal-stalled'`) after
|
|
346
|
+
* {@link DEFAULT_ATTACH_JOURNAL_WAIT_MS} rather than tailing an empty file it
|
|
347
|
+
* just created, for ever, with no error and no log line. Callers that DO have a
|
|
348
|
+
* store — `runner.ts` on an attach — run the preflight as well, for the sharper
|
|
349
|
+
* diagnosis.
|
|
350
|
+
*/
|
|
351
|
+
export function readJournal(
|
|
352
|
+
handle: SandboxHandle,
|
|
353
|
+
options: ReadJournalOptions,
|
|
354
|
+
): AsyncIterable<JournalLine> {
|
|
355
|
+
const strategy = options.strategy ?? journalReadStrategy(handle)
|
|
356
|
+
return strategy === 'follow'
|
|
357
|
+
? followJournal(handle, options)
|
|
358
|
+
: pollJournal(handle, options)
|
|
359
|
+
}
|