@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/dist/esm/file-diff.d.ts +19 -0
- package/dist/esm/file-diff.js +145 -0
- package/dist/esm/file-diff.js.map +1 -0
- package/dist/esm/middleware.js +90 -11
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/sandbox.d.ts +15 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/watch.d.ts +7 -0
- package/dist/esm/watch.js +135 -25
- package/dist/esm/watch.js.map +1 -1
- package/package.json +3 -3
- package/src/file-diff.ts +240 -0
- package/src/middleware.ts +131 -12
- package/src/sandbox.ts +11 -9
- package/src/watch.ts +233 -31
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:
|
|
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 (
|
|
154
|
-
const runtime = getSandboxRuntime(ctx, { optional: true })
|
|
234
|
+
if (fe.enabled) {
|
|
155
235
|
watcher = await watchWorkspace(handle, {
|
|
156
236
|
onEvent: (event: SandboxFileEvent) => {
|
|
157
|
-
|
|
158
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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:
|
|
26
|
-
onFileCreate?: (e:
|
|
27
|
-
onFileChange?: (e:
|
|
28
|
-
onFileDelete?: (e:
|
|
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).
|
|
63
|
-
|
|
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).
|
|
87
|
-
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
|
97
|
+
return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
|
|
84
98
|
}
|
|
85
99
|
|
|
86
|
-
/**
|
|
87
|
-
|
|
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
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
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(
|
|
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(
|
|
248
|
+
const { onEvent, root, ignore, intervalMs, logger } = options
|
|
249
|
+
const command = buildFindCommand(ignore)
|
|
184
250
|
const controller = new AbortController()
|
|
185
251
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
192
|
-
? parseFindOutput(result.stdout)
|
|
193
|
-
: new Map<string, string>()
|
|
334
|
+
return null
|
|
194
335
|
}
|
|
195
336
|
|
|
196
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
432
|
+
logger?: InternalLogger,
|
|
433
|
+
): Promise<{ files: Set<string>; rootOk: boolean }> {
|
|
240
434
|
const files = new Set<string>()
|
|
241
|
-
|
|
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
|
}
|