@tanstack/ai-sandbox 0.2.2 → 0.2.3

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/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 known = await collectPaths(handle, root, ignore)
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(() => undefined)
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
- const onAbort = (): void => void subscription.stop().catch(() => undefined)
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(() => undefined)
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
- const snapshot = async (): Promise<Map<string, string>> => {
200
- const result = await handle.process.exec(command, {
201
- cwd: root,
202
- signal: controller.signal,
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 result.exitCode === 0
205
- ? parseFindOutput(result.stdout, root)
206
- : new Map<string, string>()
334
+ return null
207
335
  }
208
336
 
209
- let previous = await snapshot()
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 next = await snapshot()
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
- // transient exec failure (e.g. mid-teardown) — try again next tick
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
- /** Recursively collect file paths under `root`, honoring `ignore`. */
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
- ): Promise<Set<string>> {
432
+ logger?: InternalLogger,
433
+ ): Promise<{ files: Set<string>; rootOk: boolean }> {
253
434
  const files = new Set<string>()
254
- const walk = async (dir: string): Promise<void> => {
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
  }