@tanstack/ai-sandbox 0.2.2 → 0.2.4
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/file-diff.d.ts +5 -2
- package/dist/esm/file-diff.js +90 -12
- package/dist/esm/file-diff.js.map +1 -1
- package/dist/esm/middleware.js +62 -16
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/watch.d.ts +7 -0
- package/dist/esm/watch.js +128 -20
- package/dist/esm/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/file-diff.ts +157 -11
- package/src/middleware.ts +91 -15
- package/src/watch.ts +213 -24
package/src/watch.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { DEFAULT_WORKSPACE_ROOT } from './bootstrap'
|
|
20
20
|
import type { SandboxHandle } from './contracts'
|
|
21
21
|
import type { SandboxFileEvent } from '@tanstack/ai'
|
|
22
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
22
23
|
|
|
23
24
|
export type { SandboxFileEvent } from '@tanstack/ai'
|
|
24
25
|
/** @deprecated alias retained for the low-level watch API. */
|
|
@@ -39,6 +40,12 @@ export interface WatchOptions {
|
|
|
39
40
|
ignore?: Array<string>
|
|
40
41
|
/** Stop watching when this signal aborts. */
|
|
41
42
|
signal?: AbortSignal
|
|
43
|
+
/**
|
|
44
|
+
* Optional logger. When present, a failed `find` poll (non-zero exit or a
|
|
45
|
+
* thrown exec) is logged instead of silently degrading the snapshot — the
|
|
46
|
+
* failure mode a plain exec-poll watcher hides.
|
|
47
|
+
*/
|
|
48
|
+
logger?: InternalLogger
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
export interface SandboxWatchHandle {
|
|
@@ -144,17 +151,49 @@ async function startNativeWatch(
|
|
|
144
151
|
handle: SandboxHandle,
|
|
145
152
|
options: WatchOptions & { root: string; ignore: Array<string> },
|
|
146
153
|
): Promise<SandboxWatchHandle> {
|
|
147
|
-
const { onEvent, root, ignore } = options
|
|
154
|
+
const { onEvent, root, ignore, logger } = options
|
|
148
155
|
const watch = handle.fs.watch
|
|
149
156
|
if (!watch) throw new Error('native watch is unavailable on this provider')
|
|
150
157
|
// Seed the set of existing files so the first event per path is classified
|
|
151
158
|
// correctly (create vs change).
|
|
152
|
-
const
|
|
159
|
+
const seed = await collectPaths(handle, root, ignore, logger)
|
|
160
|
+
const known = seed.files
|
|
161
|
+
// If the ROOT list failed, `known` is untrustworthy — every pre-existing
|
|
162
|
+
// file would misclassify as `create` on its first edit. Re-seed lazily on
|
|
163
|
+
// the next event(s): by the time real activity arrives the fs has usually
|
|
164
|
+
// recovered, and re-listing then establishes the baseline. Dedupe concurrent
|
|
165
|
+
// re-seeds behind a single in-flight promise.
|
|
166
|
+
// ponytail: a file genuinely CREATED in the narrow window between the failed
|
|
167
|
+
// seed and the first event gets picked up by the re-seed and so mislabels as
|
|
168
|
+
// `change` once. That's strictly better than the whole-run mislabel a
|
|
169
|
+
// never-recovered empty seed causes, and `diff()` is correct regardless.
|
|
170
|
+
let seeded = seed.rootOk
|
|
171
|
+
let reseeding: Promise<void> | null = null
|
|
172
|
+
const ensureSeeded = (): Promise<void> => {
|
|
173
|
+
if (seeded) return Promise.resolve()
|
|
174
|
+
if (!reseeding) {
|
|
175
|
+
reseeding = collectPaths(handle, root, ignore, logger).then((r) => {
|
|
176
|
+
if (r.rootOk) {
|
|
177
|
+
for (const p of r.files) known.add(p)
|
|
178
|
+
seeded = true
|
|
179
|
+
logger?.sandbox(
|
|
180
|
+
'sandbox watch: re-seeded after failed initial seed',
|
|
181
|
+
{
|
|
182
|
+
root,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
reseeding = null
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return reseeding
|
|
190
|
+
}
|
|
153
191
|
|
|
154
192
|
const subscription = await watch(root, (raw) => {
|
|
155
193
|
const path = raw.path
|
|
156
194
|
if (isIgnored(path, ignore)) return
|
|
157
195
|
void (async () => {
|
|
196
|
+
await ensureSeeded()
|
|
158
197
|
const exists = await handle.fs.exists(path)
|
|
159
198
|
const timestamp = Date.now()
|
|
160
199
|
if (!exists) {
|
|
@@ -166,14 +205,28 @@ async function startNativeWatch(
|
|
|
166
205
|
known.add(path)
|
|
167
206
|
onEvent({ type: 'create', path, timestamp })
|
|
168
207
|
}
|
|
169
|
-
})().catch(() =>
|
|
208
|
+
})().catch((error: unknown) => {
|
|
209
|
+
// A failed classify (e.g. `fs.exists` threw) drops this file's event —
|
|
210
|
+
// log it so a missing diff isn't silent (the whole point of the watcher).
|
|
211
|
+
logger?.warn('sandbox watch: native event classify failed', {
|
|
212
|
+
path,
|
|
213
|
+
error,
|
|
214
|
+
})
|
|
215
|
+
})
|
|
170
216
|
})
|
|
171
217
|
|
|
172
|
-
|
|
218
|
+
// A failed `subscription.stop()` can leak an OS-level watch — log rather
|
|
219
|
+
// than swallow it silently.
|
|
220
|
+
const logStopFailure = (error: unknown): void =>
|
|
221
|
+
logger?.warn('sandbox watch: native subscription.stop() failed', {
|
|
222
|
+
root,
|
|
223
|
+
error,
|
|
224
|
+
})
|
|
225
|
+
const onAbort = (): void => void subscription.stop().catch(logStopFailure)
|
|
173
226
|
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
174
227
|
// The signal may have aborted during the awaits above (the once-listener
|
|
175
228
|
// would have missed it) — tear down now if so.
|
|
176
|
-
if (options.signal?.aborted) void subscription.stop().catch(
|
|
229
|
+
if (options.signal?.aborted) void subscription.stop().catch(logStopFailure)
|
|
177
230
|
|
|
178
231
|
return {
|
|
179
232
|
stop: async () => {
|
|
@@ -192,33 +245,154 @@ async function startPollWatch(
|
|
|
192
245
|
intervalMs: number
|
|
193
246
|
},
|
|
194
247
|
): Promise<SandboxWatchHandle> {
|
|
195
|
-
const { onEvent, root, ignore, intervalMs } = options
|
|
248
|
+
const { onEvent, root, ignore, intervalMs, logger } = options
|
|
196
249
|
const command = buildFindCommand(ignore)
|
|
197
250
|
const controller = new AbortController()
|
|
198
251
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
252
|
+
// A poll result: the parsed snapshot plus whether `find` completed cleanly.
|
|
253
|
+
// `null` means the poll produced no usable output at all (thrown exec, or a
|
|
254
|
+
// non-zero exit with empty stdout) — callers preserve the previous snapshot.
|
|
255
|
+
// Collapsing a failed poll to `{}` would make the next diff fabricate a
|
|
256
|
+
// `delete` for every tracked file (and a `create` for each on recovery) —
|
|
257
|
+
// one transient `find` blip would fan a phantom storm out to hooks/stream.
|
|
258
|
+
interface Poll {
|
|
259
|
+
map: Map<string, string>
|
|
260
|
+
/** `false` when `find` exited non-zero but still printed rows (partial). */
|
|
261
|
+
complete: boolean
|
|
262
|
+
}
|
|
263
|
+
// Escalate a steady-state poll throw to `warn` after this many in a row.
|
|
264
|
+
const STEADY_STATE_THROW_WARN_AFTER = 3
|
|
265
|
+
let consecutiveThrows = 0
|
|
266
|
+
const snapshot = async (isInitial = false): Promise<Poll | null> => {
|
|
267
|
+
let result
|
|
268
|
+
try {
|
|
269
|
+
result = await handle.process.exec(command, {
|
|
270
|
+
cwd: root,
|
|
271
|
+
signal: controller.signal,
|
|
272
|
+
})
|
|
273
|
+
consecutiveThrows = 0 // exec returned (any exit code) — the seam is alive
|
|
274
|
+
} catch (error) {
|
|
275
|
+
// Thrown exec — container not ready, `find` seam rejects, or a
|
|
276
|
+
// mid-teardown abort. Treat as a failed poll so BOTH the initial seed
|
|
277
|
+
// and every tick preserve `previous` instead of rejecting setup (which
|
|
278
|
+
// would crash the run and leak the sandbox) or the interval.
|
|
279
|
+
if (isInitial) {
|
|
280
|
+
// The INITIAL poll can't be a teardown (a pre-aborted signal is guarded
|
|
281
|
+
// in `watchWorkspace`), so a throw here is an unambiguous anomaly (`find`
|
|
282
|
+
// missing, container never ready) that leaves the watcher dead for the
|
|
283
|
+
// whole run — surface it at `warn`.
|
|
284
|
+
logger?.warn('sandbox watch: initial `find` poll threw', {
|
|
285
|
+
root,
|
|
286
|
+
error,
|
|
287
|
+
})
|
|
288
|
+
} else if (controller.signal.aborted) {
|
|
289
|
+
// Mid-teardown abort — expected, stay quiet.
|
|
290
|
+
logger?.sandbox('sandbox watch: `find` poll threw during teardown', {
|
|
291
|
+
root,
|
|
292
|
+
error,
|
|
293
|
+
})
|
|
294
|
+
} else {
|
|
295
|
+
// Steady-state throw while NOT tearing down. One is usually a transient
|
|
296
|
+
// blip (→ `sandbox`), but a run of them means the exec seam is wedged:
|
|
297
|
+
// every poll returns null and the watcher emits nothing for the rest of
|
|
298
|
+
// the run. That silent-death case escalates to `warn` (on by default).
|
|
299
|
+
consecutiveThrows += 1
|
|
300
|
+
if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) {
|
|
301
|
+
logger?.warn('sandbox watch: `find` poll threw repeatedly', {
|
|
302
|
+
root,
|
|
303
|
+
error,
|
|
304
|
+
consecutiveThrows,
|
|
305
|
+
})
|
|
306
|
+
} else {
|
|
307
|
+
logger?.sandbox('sandbox watch: `find` poll threw', { root, error })
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
if (result.exitCode === 0) {
|
|
313
|
+
return { map: parseFindOutput(result.stdout, root), complete: true }
|
|
314
|
+
}
|
|
315
|
+
// Non-zero exit doesn't mean "no data": GNU `find` exits >0 on the first
|
|
316
|
+
// permission-denied entry it hits mid-traversal (common in containers, and
|
|
317
|
+
// the ignore list is a `-not -path` filter, not `-prune`, so `find` still
|
|
318
|
+
// descends into unreadable dirs) yet still prints every readable file. Use
|
|
319
|
+
// that partial output — marked `complete: false` so the tick merges rather
|
|
320
|
+
// than diffs it — instead of blinding the watcher for the whole run. Only a
|
|
321
|
+
// non-zero exit with NO output is a truly failed poll.
|
|
322
|
+
if (result.stdout !== '') {
|
|
323
|
+
logger?.sandbox(
|
|
324
|
+
'sandbox watch: `find` non-zero exit with partial output',
|
|
325
|
+
{ root, exitCode: result.exitCode, stderr: result.stderr },
|
|
326
|
+
)
|
|
327
|
+
return { map: parseFindOutput(result.stdout, root), complete: false }
|
|
328
|
+
}
|
|
329
|
+
logger?.warn('sandbox watch: `find` poll exited non-zero with no output', {
|
|
330
|
+
root,
|
|
331
|
+
exitCode: result.exitCode,
|
|
332
|
+
stderr: result.stderr,
|
|
203
333
|
})
|
|
204
|
-
return
|
|
205
|
-
? parseFindOutput(result.stdout, root)
|
|
206
|
-
: new Map<string, string>()
|
|
334
|
+
return null
|
|
207
335
|
}
|
|
208
336
|
|
|
209
|
-
|
|
337
|
+
// `null` until the first poll that yields usable output. A failed INITIAL
|
|
338
|
+
// poll must NOT seed an empty baseline — the first successful poll would then
|
|
339
|
+
// diff against `{}` and fabricate a `create` for every pre-existing file. So
|
|
340
|
+
// the first non-null snapshot is adopted as the baseline WITHOUT diffing.
|
|
341
|
+
let previous: Map<string, string> | null = null
|
|
342
|
+
// Whether `previous` was established from a COMPLETE poll. A baseline seeded
|
|
343
|
+
// from a PARTIAL poll is provisional — files unreadable during that poll are
|
|
344
|
+
// absent from it and would later fabricate `create`s when they recover — so
|
|
345
|
+
// the first complete poll re-baselines without diffing.
|
|
346
|
+
let seededFromComplete = false
|
|
347
|
+
{
|
|
348
|
+
const poll = await snapshot(true)
|
|
349
|
+
if (poll) {
|
|
350
|
+
previous = poll.map
|
|
351
|
+
seededFromComplete = poll.complete
|
|
352
|
+
}
|
|
353
|
+
}
|
|
210
354
|
const state = { running: true }
|
|
211
355
|
|
|
212
356
|
const tick = async (): Promise<void> => {
|
|
213
357
|
if (!state.running) return
|
|
214
358
|
try {
|
|
215
|
-
const
|
|
359
|
+
const poll = await snapshot()
|
|
360
|
+
// Failed poll — keep `previous` and retry next tick (see `snapshot`).
|
|
361
|
+
if (poll === null) return
|
|
362
|
+
if (previous === null) {
|
|
363
|
+
// First usable snapshot after a failed initial poll — seed, don't diff.
|
|
364
|
+
previous = poll.map
|
|
365
|
+
seededFromComplete = poll.complete
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (!seededFromComplete && poll.complete) {
|
|
369
|
+
// First complete poll after a provisional (partial) seed — re-baseline
|
|
370
|
+
// WITHOUT diffing, so files merely unreadable at seed time don't
|
|
371
|
+
// fabricate `create`s. (Real creates during this degraded-startup
|
|
372
|
+
// window are missed — an acceptable trade for not fabricating events.)
|
|
373
|
+
logger?.sandbox(
|
|
374
|
+
'sandbox watch: re-baselined after provisional partial seed',
|
|
375
|
+
{ root },
|
|
376
|
+
)
|
|
377
|
+
previous = poll.map
|
|
378
|
+
seededFromComplete = true
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
// A partial (non-`complete`) poll can't distinguish "deleted" from
|
|
382
|
+
// "transiently unreadable this poll", so MERGE it over `previous`: pick
|
|
383
|
+
// up new/changed files without fabricating a `delete` for a path this
|
|
384
|
+
// poll simply couldn't see. A real deletion still surfaces on the next
|
|
385
|
+
// complete poll.
|
|
386
|
+
const next = poll.complete
|
|
387
|
+
? poll.map
|
|
388
|
+
: new Map([...previous, ...poll.map])
|
|
216
389
|
for (const event of diffSnapshots(previous, next, Date.now())) {
|
|
217
390
|
onEvent(event)
|
|
218
391
|
}
|
|
219
392
|
previous = next
|
|
220
|
-
} catch {
|
|
221
|
-
//
|
|
393
|
+
} catch (error) {
|
|
394
|
+
// Defensive: a throw from diff dispatch — preserve `previous`, retry.
|
|
395
|
+
logger?.sandbox('sandbox watch: tick failed', { root, error })
|
|
222
396
|
}
|
|
223
397
|
}
|
|
224
398
|
|
|
@@ -244,26 +418,41 @@ async function startPollWatch(
|
|
|
244
418
|
return { stop }
|
|
245
419
|
}
|
|
246
420
|
|
|
247
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Recursively collect file paths under `root`, honoring `ignore`. `rootOk` is
|
|
423
|
+
* `false` when the ROOT `list` itself failed — the seed is then untrustworthy
|
|
424
|
+
* (empty/partial), which the native watcher uses to trigger a lazy re-seed. A
|
|
425
|
+
* failed *subdirectory* list is logged but doesn't flip `rootOk` (its files are
|
|
426
|
+
* simply absent, a smaller misclassification surface).
|
|
427
|
+
*/
|
|
248
428
|
async function collectPaths(
|
|
249
429
|
handle: SandboxHandle,
|
|
250
430
|
root: string,
|
|
251
431
|
ignore: Array<string>,
|
|
252
|
-
|
|
432
|
+
logger?: InternalLogger,
|
|
433
|
+
): Promise<{ files: Set<string>; rootOk: boolean }> {
|
|
253
434
|
const files = new Set<string>()
|
|
254
|
-
|
|
435
|
+
let rootOk = true
|
|
436
|
+
const walk = async (dir: string, isRoot: boolean): Promise<void> => {
|
|
255
437
|
let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>
|
|
256
438
|
try {
|
|
257
439
|
entries = await handle.fs.list(dir)
|
|
258
|
-
} catch {
|
|
440
|
+
} catch (error) {
|
|
441
|
+
// A dir we can't list is seeded as empty, so its existing files would
|
|
442
|
+
// later misclassify as `create` on first edit — log rather than hide it.
|
|
443
|
+
if (isRoot) rootOk = false
|
|
444
|
+
logger?.warn('sandbox watch: failed to list directory while seeding', {
|
|
445
|
+
dir,
|
|
446
|
+
error,
|
|
447
|
+
})
|
|
259
448
|
return
|
|
260
449
|
}
|
|
261
450
|
for (const entry of entries) {
|
|
262
451
|
if (ignore.includes(entry.name)) continue
|
|
263
|
-
if (entry.type === 'dir') await walk(entry.path)
|
|
452
|
+
if (entry.type === 'dir') await walk(entry.path, false)
|
|
264
453
|
else files.add(entry.path)
|
|
265
454
|
}
|
|
266
455
|
}
|
|
267
|
-
await walk(root)
|
|
268
|
-
return files
|
|
456
|
+
await walk(root, true)
|
|
457
|
+
return { files, rootOk }
|
|
269
458
|
}
|