@tanstack/ai-sandbox 0.2.1 → 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/middleware.ts CHANGED
@@ -24,15 +24,18 @@ import {
24
24
  provideSandboxPolicy,
25
25
  } from './capabilities'
26
26
  import { computeWorkspaceHash } from './key'
27
+ import { buildFileHookEvent, resolveFileEvents } from './file-diff'
27
28
  import { ProjectionCapability, provideWorkspaceProjection } from './projection'
28
29
  import { resolveSecret } from './secrets'
29
30
  import { watchWorkspace } from './watch'
30
31
  import { DEFAULT_WORKSPACE_ROOT } from './bootstrap'
32
+ import type { InternalLogger } from '@tanstack/ai/adapter-internals'
31
33
  import type {
32
34
  AbortInfo,
33
35
  ChatMiddlewareContext,
34
36
  DefinedChatMiddleware,
35
37
  SandboxFileEvent,
38
+ SandboxFileHookEvent,
36
39
  } from '@tanstack/ai'
37
40
  import type { SandboxHandle } from './contracts'
38
41
  import type {
@@ -47,10 +50,38 @@ interface SandboxRunState {
47
50
  handle: SandboxHandle
48
51
  ensureCtx: SandboxEnsureContext
49
52
  watcher?: SandboxWatchHandle
53
+ /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`
54
+ * watcher callback, awaited before teardown so a pending diff isn't
55
+ * dropped when the run finishes/aborts/errors mid-computation. */
56
+ pendingDiffs: Array<Promise<void>>
57
+ /** Logger captured at setup, so terminal hooks can log watcher teardown. */
58
+ logger?: InternalLogger
50
59
  }
51
60
 
52
61
  const runState = new WeakMap<object, SandboxRunState>()
53
62
 
63
+ /**
64
+ * Stop the watcher and drain any in-flight `diff()` promises before teardown,
65
+ * so the final file's diff isn't dropped when a run finishes/aborts/errors
66
+ * mid-computation. The `pendingDiffs` await is the load-bearing line — without
67
+ * it a deferred diff resolves after the run is gone and its chunk is lost.
68
+ */
69
+ async function drainWatcher(
70
+ state: SandboxRunState,
71
+ phase: 'finish' | 'abort' | 'error',
72
+ ): Promise<void> {
73
+ // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of
74
+ // here, or the caller skips the `definition.destroy(...)` that follows —
75
+ // leaking the sandbox on exactly the abort path that must ALWAYS tear down.
76
+ try {
77
+ await state.watcher?.stop()
78
+ } catch (error) {
79
+ state.logger?.warn('sandbox watcher stop failed', { phase, error })
80
+ }
81
+ await Promise.allSettled(state.pendingDiffs)
82
+ if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })
83
+ }
84
+
54
85
  /** Defensively pull tenant scoping out of the runtime context, if present. */
55
86
  function tenantFrom(
56
87
  context: unknown,
@@ -77,11 +108,14 @@ function buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {
77
108
  /**
78
109
  * Dispatch a sandbox file event to the per-type hooks declared on the
79
110
  * definition. Errors in individual hooks are swallowed so one bad hook
80
- * cannot break the run.
111
+ * cannot break the run — but are logged under the `errors` category first, so
112
+ * a throwing hook is observable (matching the run-scoped path in the engine
113
+ * and the behavior the observability docs promise).
81
114
  */
82
115
  async function dispatchDefinitionHooks(
83
116
  hooks: SandboxHooks | undefined,
84
- event: SandboxFileEvent,
117
+ event: SandboxFileHookEvent,
118
+ logger?: InternalLogger,
85
119
  ): Promise<void> {
86
120
  if (!hooks) return
87
121
  const typed = (
@@ -95,8 +129,14 @@ async function dispatchDefinitionHooks(
95
129
  if (!fn) continue
96
130
  try {
97
131
  await fn(event)
98
- } catch {
99
- // swallowed — one bad hook must not break the run
132
+ } catch (error) {
133
+ // swallowed — one bad hook must not break the run — but logged so the
134
+ // failure isn't invisible.
135
+ logger?.errors('sandbox file hook failed', {
136
+ path: event.path,
137
+ type: event.type,
138
+ error,
139
+ })
100
140
  }
101
141
  }
102
142
  }
@@ -122,6 +162,45 @@ export function withSandbox(
122
162
  provideSandbox(ctx, handle)
123
163
  if (definition.policy) provideSandboxPolicy(ctx, definition.policy)
124
164
 
165
+ // Pull the runtime (and its logger) up front so `baseSha` capture and
166
+ // hook dispatch below can log through the same `sandbox`/`errors`
167
+ // categories the engine uses.
168
+ const runtime = getSandboxRuntime(ctx, { optional: true })
169
+ const logger = runtime?.logger
170
+
171
+ const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT
172
+ let baseSha = ''
173
+ try {
174
+ const shaRes = await handle.process.exec('git rev-parse HEAD', {
175
+ cwd: watchRoot,
176
+ })
177
+ if (shaRes.exitCode === 0) {
178
+ baseSha = shaRes.stdout.trim()
179
+ logger?.sandbox('sandbox git baseline captured', {
180
+ root: watchRoot,
181
+ baseSha,
182
+ })
183
+ } else {
184
+ // Non-zero exit: either not a git repository (non-git workspace) or a
185
+ // repo with no commits (no HEAD). Expected, but it silently degrades
186
+ // every subsequent diff to a full-file add-patch, so surface it
187
+ // under `sandbox` (with stderr) rather than leaving nothing to grep.
188
+ logger?.sandbox('sandbox git baseline unavailable (non-zero exit)', {
189
+ root: watchRoot,
190
+ exitCode: shaRes.exitCode,
191
+ stderr: shaRes.stderr,
192
+ })
193
+ }
194
+ } catch (error) {
195
+ // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''
196
+ // and accessors fall back, but this is a real anomaly, not a plain
197
+ // non-git workspace, so warn.
198
+ logger?.warn('sandbox git baseline capture failed', {
199
+ root: watchRoot,
200
+ error,
201
+ })
202
+ }
203
+
125
204
  const workspace = definition.workspace
126
205
  if (workspace !== undefined) {
127
206
  const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT
@@ -149,19 +228,59 @@ export function withSandbox(
149
228
  const hooks = definition.hooks
150
229
  await hooks?.onReady?.(handle)
151
230
 
231
+ const fe = resolveFileEvents(definition.fileEvents)
232
+ const pendingDiffs: Array<Promise<void>> = []
152
233
  let watcher: SandboxWatchHandle | undefined
153
- if (definition.fileEvents !== false) {
154
- const runtime = getSandboxRuntime(ctx, { optional: true })
234
+ if (fe.enabled) {
155
235
  watcher = await watchWorkspace(handle, {
156
236
  onEvent: (event: SandboxFileEvent) => {
157
- void dispatchDefinitionHooks(hooks, event)
158
- runtime?.emit(event)
237
+ const enriched = buildFileHookEvent(
238
+ handle,
239
+ watchRoot,
240
+ baseSha,
241
+ event,
242
+ logger,
243
+ )
244
+ void dispatchDefinitionHooks(hooks, enriched, logger)
245
+ runtime?.emit(enriched)
246
+ if (fe.diff) {
247
+ pendingDiffs.push(
248
+ enriched
249
+ .diff()
250
+ .then((diff) => {
251
+ runtime?.emitFileDiff({ path: event.path, diff })
252
+ })
253
+ .catch((error: unknown) => {
254
+ logger?.warn('sandbox file diff emit failed', {
255
+ path: event.path,
256
+ error,
257
+ })
258
+ }),
259
+ )
260
+ }
159
261
  },
262
+ // Watch the SAME root the enrichment layer relativizes against
263
+ // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`
264
+ // capture). Without this the watcher defaults to `/workspace` while
265
+ // enrichment uses `watchRoot`, so a custom `workspace.root` makes the
266
+ // two look at different directories and git pathspecs break.
267
+ root: watchRoot,
160
268
  ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
269
+ ...(logger !== undefined ? { logger } : {}),
270
+ })
271
+ logger?.sandbox('sandbox watcher started', {
272
+ root: watchRoot,
273
+ diff: fe.diff,
161
274
  })
162
275
  }
163
276
 
164
- runState.set(ctx, { handle, ensureCtx, ...(watcher ? { watcher } : {}) })
277
+ runState.set(ctx, {
278
+ handle,
279
+ ensureCtx,
280
+ pendingDiffs,
281
+ ...(watcher ? { watcher } : {}),
282
+ ...(logger !== undefined ? { logger } : {}),
283
+ })
165
284
  },
166
285
 
167
286
  async onFinish(ctx) {
@@ -169,7 +288,7 @@ export function withSandbox(
169
288
  if (!state) return
170
289
  const { handle, ensureCtx } = state
171
290
 
172
- await state.watcher?.stop()
291
+ await drainWatcher(state, 'finish')
173
292
 
174
293
  const lifecycle = definition.lifecycle
175
294
 
@@ -203,7 +322,7 @@ export function withSandbox(
203
322
  const state = runState.get(ctx)
204
323
  if (!state) return
205
324
 
206
- await state.watcher?.stop()
325
+ await drainWatcher(state, 'abort')
207
326
 
208
327
  // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.
209
328
  // The in-sandbox agent process is not killed by closing its IO stream
@@ -219,7 +338,7 @@ export function withSandbox(
219
338
  const state = runState.get(ctx)
220
339
  if (!state) return
221
340
 
222
- await state.watcher?.stop()
341
+ await drainWatcher(state, 'error')
223
342
  await definition.hooks?.onError?.(info.error)
224
343
 
225
344
  // On failure, only tear down when the lifecycle says so; otherwise leave
package/src/sandbox.ts CHANGED
@@ -10,7 +10,7 @@ import { bootstrapWorkspace } from './bootstrap'
10
10
  import { resolveAllSecrets } from './secrets'
11
11
  import { computeSandboxKey } from './key'
12
12
  import { InMemoryLockStore, InMemorySandboxStore } from './store'
13
- import type { SandboxFileEvent } from '@tanstack/ai'
13
+ import type { SandboxFileHookEvent } from '@tanstack/ai'
14
14
  import type { SandboxHandle, SandboxProvider } from './contracts'
15
15
  import type { SandboxKeyInput } from './key'
16
16
  import type { LockStore, SandboxStore } from './store'
@@ -22,10 +22,10 @@ import type { WorkspaceDefinition } from './workspace'
22
22
  * create/change/delete during a chat run; lifecycle hooks fire server-side.
23
23
  */
24
24
  export interface SandboxHooks {
25
- onFile?: (e: SandboxFileEvent) => void | Promise<void>
26
- onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>
27
- onFileChange?: (e: SandboxFileEvent) => void | Promise<void>
28
- onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>
25
+ onFile?: (e: SandboxFileHookEvent) => void | Promise<void>
26
+ onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>
27
+ onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>
28
+ onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>
29
29
  onReady?: (handle: SandboxHandle) => void | Promise<void>
30
30
  onError?: (err: unknown) => void | Promise<void>
31
31
  onDestroy?: () => void | Promise<void>
@@ -59,8 +59,9 @@ export interface SandboxConfig {
59
59
  lifecycle?: SandboxLifecycle
60
60
  /** Sandbox-scoped file/lifecycle hooks. */
61
61
  hooks?: SandboxHooks
62
- /** Watch the workspace for file events (default true). Set false to disable. */
63
- fileEvents?: boolean
62
+ /** Watch the workspace for file events (default true). `false` disables the
63
+ * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
64
+ fileEvents?: boolean | { diff?: boolean }
64
65
  }
65
66
 
66
67
  /** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
@@ -83,8 +84,9 @@ export interface SandboxDefinition {
83
84
  readonly lifecycle?: SandboxLifecycle
84
85
  /** Sandbox-scoped file/lifecycle hooks. */
85
86
  readonly hooks?: SandboxHooks
86
- /** Watch the workspace for file events (default true). Set false to disable. */
87
- readonly fileEvents?: boolean
87
+ /** Watch the workspace for file events (default true). `false` disables the
88
+ * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */
89
+ readonly fileEvents?: boolean | { diff?: boolean }
88
90
  /** Compound instance key for a given run context. */
89
91
  key: (ctx: SandboxEnsureContext) => string
90
92
  /** Resume-or-create the sandbox for this thread/run. */
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 {
@@ -75,16 +82,28 @@ export function diffSnapshots(
75
82
  return events
76
83
  }
77
84
 
78
- /** Build the `find` command that prints `mtime\tsize\tpath` for every file. */
79
- function buildFindCommand(root: string, ignore: Array<string>): string {
85
+ /**
86
+ * Build the `find` command that prints `mtime\tsize\tpath` for every file.
87
+ * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a
88
+ * provider's `exec` maps only `cwd` onto the real filesystem, not literal path
89
+ * arguments, so `find <virtual-root>` would look at a non-existent host path on
90
+ * mapped-root providers (e.g. local-process). Emitted `%p` values are
91
+ * root-normalized in {@link parseFindOutput}.
92
+ */
93
+ function buildFindCommand(ignore: Array<string>): string {
80
94
  const prunes = ignore
81
95
  .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)
82
96
  .join(' ')
83
- return `find ${q(root)} -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
97
+ return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
84
98
  }
85
99
 
86
- /** Parse `find -printf` output into a `Map<path, signature>`. */
87
- function parseFindOutput(stdout: string): Map<string, string> {
100
+ /**
101
+ * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints
102
+ * paths like `./sub/file`; map them back under `root` so event paths match the
103
+ * native-watch shape (`<root>/sub/file`).
104
+ */
105
+ function parseFindOutput(stdout: string, root: string): Map<string, string> {
106
+ const base = root.replace(/\/+$/, '')
88
107
  const snapshot = new Map<string, string>()
89
108
  for (const line of stdout.split('\n')) {
90
109
  if (line === '') continue
@@ -93,7 +112,8 @@ function parseFindOutput(stdout: string): Map<string, string> {
93
112
  if (firstTab === -1 || secondTab === -1) continue
94
113
  const mtime = line.slice(0, firstTab)
95
114
  const size = line.slice(firstTab + 1, secondTab)
96
- const path = line.slice(secondTab + 1)
115
+ const rel = line.slice(secondTab + 1).replace(/^\.\/?/, '')
116
+ const path = rel === '' ? base : `${base}/${rel}`
97
117
  snapshot.set(path, `${mtime}\t${size}`)
98
118
  }
99
119
  return snapshot
@@ -131,17 +151,49 @@ async function startNativeWatch(
131
151
  handle: SandboxHandle,
132
152
  options: WatchOptions & { root: string; ignore: Array<string> },
133
153
  ): Promise<SandboxWatchHandle> {
134
- const { onEvent, root, ignore } = options
154
+ const { onEvent, root, ignore, logger } = options
135
155
  const watch = handle.fs.watch
136
156
  if (!watch) throw new Error('native watch is unavailable on this provider')
137
157
  // Seed the set of existing files so the first event per path is classified
138
158
  // correctly (create vs change).
139
- 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
+ }
140
191
 
141
192
  const subscription = await watch(root, (raw) => {
142
193
  const path = raw.path
143
194
  if (isIgnored(path, ignore)) return
144
195
  void (async () => {
196
+ await ensureSeeded()
145
197
  const exists = await handle.fs.exists(path)
146
198
  const timestamp = Date.now()
147
199
  if (!exists) {
@@ -153,14 +205,28 @@ async function startNativeWatch(
153
205
  known.add(path)
154
206
  onEvent({ type: 'create', path, timestamp })
155
207
  }
156
- })().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
+ })
157
216
  })
158
217
 
159
- 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)
160
226
  options.signal?.addEventListener('abort', onAbort, { once: true })
161
227
  // The signal may have aborted during the awaits above (the once-listener
162
228
  // would have missed it) — tear down now if so.
163
- if (options.signal?.aborted) void subscription.stop().catch(() => undefined)
229
+ if (options.signal?.aborted) void subscription.stop().catch(logStopFailure)
164
230
 
165
231
  return {
166
232
  stop: async () => {
@@ -179,33 +245,154 @@ async function startPollWatch(
179
245
  intervalMs: number
180
246
  },
181
247
  ): Promise<SandboxWatchHandle> {
182
- const { onEvent, root, ignore, intervalMs } = options
183
- const command = buildFindCommand(root, ignore)
248
+ const { onEvent, root, ignore, intervalMs, logger } = options
249
+ const command = buildFindCommand(ignore)
184
250
  const controller = new AbortController()
185
251
 
186
- const snapshot = async (): Promise<Map<string, string>> => {
187
- const result = await handle.process.exec(command, {
188
- cwd: root,
189
- 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,
190
333
  })
191
- return result.exitCode === 0
192
- ? parseFindOutput(result.stdout)
193
- : new Map<string, string>()
334
+ return null
194
335
  }
195
336
 
196
- 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
+ }
197
354
  const state = { running: true }
198
355
 
199
356
  const tick = async (): Promise<void> => {
200
357
  if (!state.running) return
201
358
  try {
202
- 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])
203
389
  for (const event of diffSnapshots(previous, next, Date.now())) {
204
390
  onEvent(event)
205
391
  }
206
392
  previous = next
207
- } catch {
208
- // 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 })
209
396
  }
210
397
  }
211
398
 
@@ -231,26 +418,41 @@ async function startPollWatch(
231
418
  return { stop }
232
419
  }
233
420
 
234
- /** 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
+ */
235
428
  async function collectPaths(
236
429
  handle: SandboxHandle,
237
430
  root: string,
238
431
  ignore: Array<string>,
239
- ): Promise<Set<string>> {
432
+ logger?: InternalLogger,
433
+ ): Promise<{ files: Set<string>; rootOk: boolean }> {
240
434
  const files = new Set<string>()
241
- const walk = async (dir: string): Promise<void> => {
435
+ let rootOk = true
436
+ const walk = async (dir: string, isRoot: boolean): Promise<void> => {
242
437
  let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>
243
438
  try {
244
439
  entries = await handle.fs.list(dir)
245
- } 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
+ })
246
448
  return
247
449
  }
248
450
  for (const entry of entries) {
249
451
  if (ignore.includes(entry.name)) continue
250
- if (entry.type === 'dir') await walk(entry.path)
452
+ if (entry.type === 'dir') await walk(entry.path, false)
251
453
  else files.add(entry.path)
252
454
  }
253
455
  }
254
- await walk(root)
255
- return files
456
+ await walk(root, true)
457
+ return { files, rootOk }
256
458
  }