@tanstack/ai-sandbox 0.2.3 → 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.
Files changed (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
package/dist/esm/watch.js CHANGED
@@ -1,254 +1,328 @@
1
- import { DEFAULT_WORKSPACE_ROOT } from "./bootstrap.js";
2
- const DEFAULT_INTERVAL_MS = 700;
3
- const DEFAULT_IGNORE = [".git", "node_modules"];
1
+ import "./bootstrap.js";
2
+ //#region src/watch.ts
3
+ /**
4
+ * Sandbox file-event hooks — observe create / change / delete of files inside a
5
+ * sandbox (e.g. as an in-sandbox agent edits the workspace).
6
+ *
7
+ * Provider-agnostic: coded against the {@link SandboxHandle} contract only.
8
+ * Two mechanisms, auto-selected:
9
+ *
10
+ * - **Native** — when a provider implements the optional `fs.watch` seam
11
+ * (local-process does, via Node `fs.watch`), OS events drive the feed with low
12
+ * latency.
13
+ * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a
14
+ * single `find … -printf` snapshot of `mtime\tsize\tpath` is taken every
15
+ * `intervalMs` and diffed. Works on any Linux container with GNU findutils
16
+ * (true for `node:*` / debian images) with no extra deps or image changes.
17
+ *
18
+ * The feed intentionally rides only the portable surface, so the same
19
+ * `watchWorkspace` call behaves identically across providers.
20
+ */
21
+ var DEFAULT_INTERVAL_MS = 700;
22
+ var DEFAULT_IGNORE = [".git", "node_modules"];
23
+ /** POSIX single-quote escape for embedding values in a shell command. */
4
24
  function q(value) {
5
- return `'${value.replace(/'/g, `'\\''`)}'`;
25
+ return `'${value.replace(/'/g, `'\\''`)}'`;
6
26
  }
27
+ /**
28
+ * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\tsize`).
29
+ * Pure — the heart of the exec-poll path, unit-tested in isolation.
30
+ */
7
31
  function diffSnapshots(prev, next, timestamp) {
8
- const events = [];
9
- for (const [path, sig] of next) {
10
- const before = prev.get(path);
11
- if (before === void 0) events.push({ type: "create", path, timestamp });
12
- else if (before !== sig) events.push({ type: "change", path, timestamp });
13
- }
14
- for (const path of prev.keys()) {
15
- if (!next.has(path)) events.push({ type: "delete", path, timestamp });
16
- }
17
- return events;
32
+ const events = [];
33
+ for (const [path, sig] of next) {
34
+ const before = prev.get(path);
35
+ if (before === void 0) events.push({
36
+ type: "create",
37
+ path,
38
+ timestamp
39
+ });
40
+ else if (before !== sig) events.push({
41
+ type: "change",
42
+ path,
43
+ timestamp
44
+ });
45
+ }
46
+ for (const path of prev.keys()) if (!next.has(path)) events.push({
47
+ type: "delete",
48
+ path,
49
+ timestamp
50
+ });
51
+ return events;
18
52
  }
53
+ /**
54
+ * Build the `find` command that prints `mtime\tsize\tpath` for every file.
55
+ * Searches `.` (relative to the exec `cwd`) rather than an absolute root: a
56
+ * provider's `exec` maps only `cwd` onto the real filesystem, not literal path
57
+ * arguments, so `find <virtual-root>` would look at a non-existent host path on
58
+ * mapped-root providers (e.g. local-process). Emitted `%p` values are
59
+ * root-normalized in {@link parseFindOutput}.
60
+ */
19
61
  function buildFindCommand(ignore) {
20
- const prunes = ignore.map((entry) => `-not -path ${q(`*/${entry}/*`)}`).join(" ");
21
- return `find . -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`;
62
+ return `find . -type f ${ignore.map((entry) => `-not -path ${q(`*/${entry}/*`)}`).join(" ")} -printf '%T@\\t%s\\t%p\\n'`;
22
63
  }
64
+ /**
65
+ * Parse `find -printf` output into a `Map<path, signature>`. `find .` prints
66
+ * paths like `./sub/file`; map them back under `root` so event paths match the
67
+ * native-watch shape (`<root>/sub/file`).
68
+ */
23
69
  function parseFindOutput(stdout, root) {
24
- const base = root.replace(/\/+$/, "");
25
- const snapshot = /* @__PURE__ */ new Map();
26
- for (const line of stdout.split("\n")) {
27
- if (line === "") continue;
28
- const firstTab = line.indexOf(" ");
29
- const secondTab = line.indexOf(" ", firstTab + 1);
30
- if (firstTab === -1 || secondTab === -1) continue;
31
- const mtime = line.slice(0, firstTab);
32
- const size = line.slice(firstTab + 1, secondTab);
33
- const rel = line.slice(secondTab + 1).replace(/^\.\/?/, "");
34
- const path = rel === "" ? base : `${base}/${rel}`;
35
- snapshot.set(path, `${mtime} ${size}`);
36
- }
37
- return snapshot;
70
+ const base = root.replace(/\/+$/, "");
71
+ const snapshot = /* @__PURE__ */ new Map();
72
+ for (const line of stdout.split("\n")) {
73
+ if (line === "") continue;
74
+ const firstTab = line.indexOf(" ");
75
+ const secondTab = line.indexOf(" ", firstTab + 1);
76
+ if (firstTab === -1 || secondTab === -1) continue;
77
+ const mtime = line.slice(0, firstTab);
78
+ const size = line.slice(firstTab + 1, secondTab);
79
+ const rel = line.slice(secondTab + 1).replace(/^\.\/?/, "");
80
+ const path = rel === "" ? base : `${base}/${rel}`;
81
+ snapshot.set(path, `${mtime}\t${size}`);
82
+ }
83
+ return snapshot;
38
84
  }
85
+ /** Whether a path should be ignored (contains a `/<entry>/` fragment). */
39
86
  function isIgnored(path, ignore) {
40
- return ignore.some((entry) => path.includes(`/${entry}/`));
87
+ return ignore.some((entry) => path.includes(`/${entry}/`));
41
88
  }
89
+ /**
90
+ * Start watching a sandbox workspace for file events. Picks the native
91
+ * `fs.watch` fast-path when the provider advertises it, otherwise polls via
92
+ * `find`. Returns a handle whose `stop()` tears everything down.
93
+ */
42
94
  async function watchWorkspace(handle, options) {
43
- const root = options.root ?? DEFAULT_WORKSPACE_ROOT;
44
- const ignore = options.ignore ?? DEFAULT_IGNORE;
45
- const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
46
- if (options.signal?.aborted) return { stop: () => Promise.resolve() };
47
- if (handle.fs.watch) {
48
- return startNativeWatch(handle, { ...options, root, ignore });
49
- }
50
- return startPollWatch(handle, { ...options, root, ignore, intervalMs });
95
+ const root = options.root ?? "/workspace";
96
+ const ignore = options.ignore ?? DEFAULT_IGNORE;
97
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
98
+ if (options.signal?.aborted) return { stop: () => Promise.resolve() };
99
+ if (handle.fs.watch) return startNativeWatch(handle, {
100
+ ...options,
101
+ root,
102
+ ignore
103
+ });
104
+ return startPollWatch(handle, {
105
+ ...options,
106
+ root,
107
+ ignore,
108
+ intervalMs
109
+ });
51
110
  }
111
+ /** Native fs.watch path: OS events, disambiguated against a known-path set. */
52
112
  async function startNativeWatch(handle, options) {
53
- const { onEvent, root, ignore, logger } = options;
54
- const watch = handle.fs.watch;
55
- if (!watch) throw new Error("native watch is unavailable on this provider");
56
- const seed = await collectPaths(handle, root, ignore, logger);
57
- const known = seed.files;
58
- let seeded = seed.rootOk;
59
- let reseeding = null;
60
- const ensureSeeded = () => {
61
- if (seeded) return Promise.resolve();
62
- if (!reseeding) {
63
- reseeding = collectPaths(handle, root, ignore, logger).then((r) => {
64
- if (r.rootOk) {
65
- for (const p of r.files) known.add(p);
66
- seeded = true;
67
- logger?.sandbox(
68
- "sandbox watch: re-seeded after failed initial seed",
69
- {
70
- root
71
- }
72
- );
73
- }
74
- reseeding = null;
75
- });
76
- }
77
- return reseeding;
78
- };
79
- const subscription = await watch(root, (raw) => {
80
- const path = raw.path;
81
- if (isIgnored(path, ignore)) return;
82
- void (async () => {
83
- await ensureSeeded();
84
- const exists = await handle.fs.exists(path);
85
- const timestamp = Date.now();
86
- if (!exists) {
87
- if (known.delete(path)) onEvent({ type: "delete", path, timestamp });
88
- return;
89
- }
90
- if (known.has(path)) onEvent({ type: "change", path, timestamp });
91
- else {
92
- known.add(path);
93
- onEvent({ type: "create", path, timestamp });
94
- }
95
- })().catch((error) => {
96
- logger?.warn("sandbox watch: native event classify failed", {
97
- path,
98
- error
99
- });
100
- });
101
- });
102
- const logStopFailure = (error) => logger?.warn("sandbox watch: native subscription.stop() failed", {
103
- root,
104
- error
105
- });
106
- const onAbort = () => void subscription.stop().catch(logStopFailure);
107
- options.signal?.addEventListener("abort", onAbort, { once: true });
108
- if (options.signal?.aborted) void subscription.stop().catch(logStopFailure);
109
- return {
110
- stop: async () => {
111
- options.signal?.removeEventListener("abort", onAbort);
112
- await subscription.stop();
113
- }
114
- };
113
+ const { onEvent, root, ignore, logger } = options;
114
+ const watch = handle.fs.watch;
115
+ if (!watch) throw new Error("native watch is unavailable on this provider");
116
+ const seed = await collectPaths(handle, root, ignore, logger);
117
+ const known = seed.files;
118
+ let seeded = seed.rootOk;
119
+ let reseeding = null;
120
+ const ensureSeeded = () => {
121
+ if (seeded) return Promise.resolve();
122
+ if (!reseeding) reseeding = collectPaths(handle, root, ignore, logger).then((r) => {
123
+ if (r.rootOk) {
124
+ for (const p of r.files) known.add(p);
125
+ seeded = true;
126
+ logger?.sandbox("sandbox watch: re-seeded after failed initial seed", { root });
127
+ }
128
+ reseeding = null;
129
+ });
130
+ return reseeding;
131
+ };
132
+ const subscription = await watch(root, (raw) => {
133
+ const path = raw.path;
134
+ if (isIgnored(path, ignore)) return;
135
+ (async () => {
136
+ await ensureSeeded();
137
+ const exists = await handle.fs.exists(path);
138
+ const timestamp = Date.now();
139
+ if (!exists) {
140
+ if (known.delete(path)) onEvent({
141
+ type: "delete",
142
+ path,
143
+ timestamp
144
+ });
145
+ return;
146
+ }
147
+ if (known.has(path)) onEvent({
148
+ type: "change",
149
+ path,
150
+ timestamp
151
+ });
152
+ else {
153
+ known.add(path);
154
+ onEvent({
155
+ type: "create",
156
+ path,
157
+ timestamp
158
+ });
159
+ }
160
+ })().catch((error) => {
161
+ logger?.warn("sandbox watch: native event classify failed", {
162
+ path,
163
+ error
164
+ });
165
+ });
166
+ });
167
+ const logStopFailure = (error) => logger?.warn("sandbox watch: native subscription.stop() failed", {
168
+ root,
169
+ error
170
+ });
171
+ const onAbort = () => void subscription.stop().catch(logStopFailure);
172
+ options.signal?.addEventListener("abort", onAbort, { once: true });
173
+ if (options.signal?.aborted) subscription.stop().catch(logStopFailure);
174
+ return { stop: async () => {
175
+ options.signal?.removeEventListener("abort", onAbort);
176
+ await subscription.stop();
177
+ } };
115
178
  }
179
+ /** Exec-poll path: snapshot `find -printf` on an interval and diff. */
116
180
  async function startPollWatch(handle, options) {
117
- const { onEvent, root, ignore, intervalMs, logger } = options;
118
- const command = buildFindCommand(ignore);
119
- const controller = new AbortController();
120
- const STEADY_STATE_THROW_WARN_AFTER = 3;
121
- let consecutiveThrows = 0;
122
- const snapshot = async (isInitial = false) => {
123
- let result;
124
- try {
125
- result = await handle.process.exec(command, {
126
- cwd: root,
127
- signal: controller.signal
128
- });
129
- consecutiveThrows = 0;
130
- } catch (error) {
131
- if (isInitial) {
132
- logger?.warn("sandbox watch: initial `find` poll threw", {
133
- root,
134
- error
135
- });
136
- } else if (controller.signal.aborted) {
137
- logger?.sandbox("sandbox watch: `find` poll threw during teardown", {
138
- root,
139
- error
140
- });
141
- } else {
142
- consecutiveThrows += 1;
143
- if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) {
144
- logger?.warn("sandbox watch: `find` poll threw repeatedly", {
145
- root,
146
- error,
147
- consecutiveThrows
148
- });
149
- } else {
150
- logger?.sandbox("sandbox watch: `find` poll threw", { root, error });
151
- }
152
- }
153
- return null;
154
- }
155
- if (result.exitCode === 0) {
156
- return { map: parseFindOutput(result.stdout, root), complete: true };
157
- }
158
- if (result.stdout !== "") {
159
- logger?.sandbox(
160
- "sandbox watch: `find` non-zero exit with partial output",
161
- { root, exitCode: result.exitCode, stderr: result.stderr }
162
- );
163
- return { map: parseFindOutput(result.stdout, root), complete: false };
164
- }
165
- logger?.warn("sandbox watch: `find` poll exited non-zero with no output", {
166
- root,
167
- exitCode: result.exitCode,
168
- stderr: result.stderr
169
- });
170
- return null;
171
- };
172
- let previous = null;
173
- let seededFromComplete = false;
174
- {
175
- const poll = await snapshot(true);
176
- if (poll) {
177
- previous = poll.map;
178
- seededFromComplete = poll.complete;
179
- }
180
- }
181
- const state = { running: true };
182
- const tick = async () => {
183
- if (!state.running) return;
184
- try {
185
- const poll = await snapshot();
186
- if (poll === null) return;
187
- if (previous === null) {
188
- previous = poll.map;
189
- seededFromComplete = poll.complete;
190
- return;
191
- }
192
- if (!seededFromComplete && poll.complete) {
193
- logger?.sandbox(
194
- "sandbox watch: re-baselined after provisional partial seed",
195
- { root }
196
- );
197
- previous = poll.map;
198
- seededFromComplete = true;
199
- return;
200
- }
201
- const next = poll.complete ? poll.map : new Map([...previous, ...poll.map]);
202
- for (const event of diffSnapshots(previous, next, Date.now())) {
203
- onEvent(event);
204
- }
205
- previous = next;
206
- } catch (error) {
207
- logger?.sandbox("sandbox watch: tick failed", { root, error });
208
- }
209
- };
210
- const timer = setInterval(() => void tick(), intervalMs);
211
- if (typeof timer.unref === "function") timer.unref();
212
- const stop = () => {
213
- if (state.running) {
214
- state.running = false;
215
- clearInterval(timer);
216
- controller.abort();
217
- options.signal?.removeEventListener("abort", onAbort);
218
- }
219
- return Promise.resolve();
220
- };
221
- const onAbort = () => void stop();
222
- options.signal?.addEventListener("abort", onAbort, { once: true });
223
- if (options.signal?.aborted) void stop();
224
- return { stop };
181
+ const { onEvent, root, ignore, intervalMs, logger } = options;
182
+ const command = buildFindCommand(ignore);
183
+ const controller = new AbortController();
184
+ const STEADY_STATE_THROW_WARN_AFTER = 3;
185
+ let consecutiveThrows = 0;
186
+ const snapshot = async (isInitial = false) => {
187
+ let result;
188
+ try {
189
+ result = await handle.process.exec(command, {
190
+ cwd: root,
191
+ signal: controller.signal
192
+ });
193
+ consecutiveThrows = 0;
194
+ } catch (error) {
195
+ if (isInitial) logger?.warn("sandbox watch: initial `find` poll threw", {
196
+ root,
197
+ error
198
+ });
199
+ else if (controller.signal.aborted) logger?.sandbox("sandbox watch: `find` poll threw during teardown", {
200
+ root,
201
+ error
202
+ });
203
+ else {
204
+ consecutiveThrows += 1;
205
+ if (consecutiveThrows >= STEADY_STATE_THROW_WARN_AFTER) logger?.warn("sandbox watch: `find` poll threw repeatedly", {
206
+ root,
207
+ error,
208
+ consecutiveThrows
209
+ });
210
+ else logger?.sandbox("sandbox watch: `find` poll threw", {
211
+ root,
212
+ error
213
+ });
214
+ }
215
+ return null;
216
+ }
217
+ if (result.exitCode === 0) return {
218
+ map: parseFindOutput(result.stdout, root),
219
+ complete: true
220
+ };
221
+ if (result.stdout !== "") {
222
+ logger?.sandbox("sandbox watch: `find` non-zero exit with partial output", {
223
+ root,
224
+ exitCode: result.exitCode,
225
+ stderr: result.stderr
226
+ });
227
+ return {
228
+ map: parseFindOutput(result.stdout, root),
229
+ complete: false
230
+ };
231
+ }
232
+ logger?.warn("sandbox watch: `find` poll exited non-zero with no output", {
233
+ root,
234
+ exitCode: result.exitCode,
235
+ stderr: result.stderr
236
+ });
237
+ return null;
238
+ };
239
+ let previous = null;
240
+ let seededFromComplete = false;
241
+ {
242
+ const poll = await snapshot(true);
243
+ if (poll) {
244
+ previous = poll.map;
245
+ seededFromComplete = poll.complete;
246
+ }
247
+ }
248
+ const state = { running: true };
249
+ const tick = async () => {
250
+ if (!state.running) return;
251
+ try {
252
+ const poll = await snapshot();
253
+ if (poll === null) return;
254
+ if (previous === null) {
255
+ previous = poll.map;
256
+ seededFromComplete = poll.complete;
257
+ return;
258
+ }
259
+ if (!seededFromComplete && poll.complete) {
260
+ logger?.sandbox("sandbox watch: re-baselined after provisional partial seed", { root });
261
+ previous = poll.map;
262
+ seededFromComplete = true;
263
+ return;
264
+ }
265
+ const next = poll.complete ? poll.map : new Map([...previous, ...poll.map]);
266
+ for (const event of diffSnapshots(previous, next, Date.now())) onEvent(event);
267
+ previous = next;
268
+ } catch (error) {
269
+ logger?.sandbox("sandbox watch: tick failed", {
270
+ root,
271
+ error
272
+ });
273
+ }
274
+ };
275
+ const timer = setInterval(() => void tick(), intervalMs);
276
+ if (typeof timer.unref === "function") timer.unref();
277
+ const stop = () => {
278
+ if (state.running) {
279
+ state.running = false;
280
+ clearInterval(timer);
281
+ controller.abort();
282
+ options.signal?.removeEventListener("abort", onAbort);
283
+ }
284
+ return Promise.resolve();
285
+ };
286
+ const onAbort = () => void stop();
287
+ options.signal?.addEventListener("abort", onAbort, { once: true });
288
+ if (options.signal?.aborted) stop();
289
+ return { stop };
225
290
  }
291
+ /**
292
+ * Recursively collect file paths under `root`, honoring `ignore`. `rootOk` is
293
+ * `false` when the ROOT `list` itself failed — the seed is then untrustworthy
294
+ * (empty/partial), which the native watcher uses to trigger a lazy re-seed. A
295
+ * failed *subdirectory* list is logged but doesn't flip `rootOk` (its files are
296
+ * simply absent, a smaller misclassification surface).
297
+ */
226
298
  async function collectPaths(handle, root, ignore, logger) {
227
- const files = /* @__PURE__ */ new Set();
228
- let rootOk = true;
229
- const walk = async (dir, isRoot) => {
230
- let entries;
231
- try {
232
- entries = await handle.fs.list(dir);
233
- } catch (error) {
234
- if (isRoot) rootOk = false;
235
- logger?.warn("sandbox watch: failed to list directory while seeding", {
236
- dir,
237
- error
238
- });
239
- return;
240
- }
241
- for (const entry of entries) {
242
- if (ignore.includes(entry.name)) continue;
243
- if (entry.type === "dir") await walk(entry.path, false);
244
- else files.add(entry.path);
245
- }
246
- };
247
- await walk(root, true);
248
- return { files, rootOk };
299
+ const files = /* @__PURE__ */ new Set();
300
+ let rootOk = true;
301
+ const walk = async (dir, isRoot) => {
302
+ let entries;
303
+ try {
304
+ entries = await handle.fs.list(dir);
305
+ } catch (error) {
306
+ if (isRoot) rootOk = false;
307
+ logger?.warn("sandbox watch: failed to list directory while seeding", {
308
+ dir,
309
+ error
310
+ });
311
+ return;
312
+ }
313
+ for (const entry of entries) {
314
+ if (ignore.includes(entry.name)) continue;
315
+ if (entry.type === "dir") await walk(entry.path, false);
316
+ else files.add(entry.path);
317
+ }
318
+ };
319
+ await walk(root, true);
320
+ return {
321
+ files,
322
+ rootOk
323
+ };
249
324
  }
250
- export {
251
- diffSnapshots,
252
- watchWorkspace
253
- };
254
- //# sourceMappingURL=watch.js.map
325
+ //#endregion
326
+ export { diffSnapshots, watchWorkspace };
327
+
328
+ //# sourceMappingURL=watch.js.map