@relayfile/local-mount 0.7.7 → 0.7.9

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/README.md CHANGED
@@ -21,7 +21,7 @@ const handle = await createMount(projectDir, mountDir, options);
21
21
 
22
22
  interface MountHandle {
23
23
  mountDir: string;
24
- syncBack(opts?: { signal?: AbortSignal }): Promise<number>;
24
+ syncBack(opts?: { signal?: AbortSignal; paths?: Iterable<string> }): Promise<number>;
25
25
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
26
26
  cleanup(): void;
27
27
  }
@@ -58,8 +58,8 @@ const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, {
58
58
 
59
59
  High-level helper that:
60
60
  1. creates a mount,
61
- 2. starts bidirectional auto-sync (see below, controllable via `autoSync`),
62
- 3. runs a CLI inside the mount,
61
+ 2. starts bidirectional auto-sync (see below, controllable via `autoSync`) and waits for the watchers to be ready when auto-sync is enabled,
62
+ 3. runs `onBeforeLaunch`, then runs a CLI inside the mount,
63
63
  4. forwards `SIGINT` and `SIGTERM`,
64
64
  5. stops auto-sync and runs a final sync-back pass after the child exits,
65
65
  6. cleans up the mount directory.
@@ -84,7 +84,10 @@ interface AutoSyncOptions {
84
84
 
85
85
  interface AutoSyncHandle {
86
86
  stop(opts?: { signal?: AbortSignal }): Promise<void>;
87
+ flushPending(opts?: { signal?: AbortSignal }): Promise<number>;
87
88
  reconcile(opts?: { signal?: AbortSignal }): Promise<number>;
89
+ getDirtyPaths(): IterableIterator<string>;
90
+ watchersHealthy(): boolean;
88
91
  totalChanges(): number;
89
92
  ready(): Promise<void>;
90
93
  }
@@ -103,6 +106,7 @@ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 }
103
106
  How it works:
104
107
  - [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW
105
108
  - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
109
+ - watcher events are tracked as dirty paths, so shutdown can flush pending path-level work and make the final sync-back proportional to the number of mount-side changes when the watcher state stayed healthy
106
110
  - `stop({ signal })` still closes watchers if aborted, but skips the final draining reconcile
107
111
  - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
108
112
 
@@ -267,6 +271,8 @@ The returned number is the count of files written back to `projectDir` in that p
267
271
 
268
272
  `syncBack({ signal })` checks for aborts between files. If the signal aborts during shutdown, it returns the partial count accumulated so far instead of throwing, which lets callers report a partial sync and still run cleanup.
269
273
 
274
+ `syncBack({ paths })` limits the pass to explicit mount-relative paths. This is used by auto-sync shutdown after `watchersHealthy()` confirms both watchers subscribed and no watcher error was observed; otherwise callers should omit `paths` to keep the full-walk safety net.
275
+
270
276
  `reconcile({ signal })` and the internal tree walk also poll for aborts between file visits so an in-flight draining scan can stop cooperatively.
271
277
 
272
278
  ## Safety constraints
@@ -46,10 +46,18 @@ export interface AutoSyncHandle {
46
46
  stop(opts?: {
47
47
  signal?: AbortSignal;
48
48
  }): Promise<void>;
49
+ /** Drain currently debounced watcher events. Falls back to reconcile if watchers are degraded. */
50
+ flushPending(opts?: {
51
+ signal?: AbortSignal;
52
+ }): Promise<number>;
49
53
  /** Force a reconcile now; returns number of files copied/deleted. */
50
54
  reconcile(opts?: {
51
55
  signal?: AbortSignal;
52
56
  }): Promise<number>;
57
+ /** Mount-side paths that still need a final one-shot syncBack check. */
58
+ getDirtyPaths(): IterableIterator<string>;
59
+ /** True once both watchers subscribed and no watcher error has been observed. */
60
+ watchersHealthy(): boolean;
53
61
  /** Cumulative files changed (copied or deleted) since autosync started. */
54
62
  totalChanges(): number;
55
63
  /** Resolves once both watchers have completed their initial scan. */
package/dist/auto-sync.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import watcher from '@parcel/watcher';
4
+ const STOP_EVENT_SETTLE_MS = 250;
4
5
  export function startAutoSync(ctx, opts = {}) {
5
6
  const scanIntervalMs = opts.scanIntervalMs ?? 10_000;
6
7
  const debounceMs = opts.debounceMs ?? 50;
@@ -9,9 +10,60 @@ export function startAutoSync(ctx, opts = {}) {
9
10
  primeState(state, ctx);
10
11
  let syncing = false;
11
12
  let pending = false;
13
+ let stopping = false;
12
14
  let stopped = false;
15
+ let watchersReadySettled = false;
16
+ let watcherDegraded = false;
13
17
  let totalChanges = 0;
18
+ const pendingPaths = new Set();
14
19
  const pendingDebounces = new Map();
20
+ const dirtyMountPaths = new Set();
21
+ const watchersHealthy = () => watchersReadySettled && !watcherDegraded;
22
+ const markWatcherDegraded = (err) => {
23
+ watcherDegraded = true;
24
+ onError(err);
25
+ };
26
+ const clearPendingDebounces = () => {
27
+ for (const t of pendingDebounces.values())
28
+ clearTimeout(t);
29
+ pendingDebounces.clear();
30
+ };
31
+ const syncPath = (relPosix) => {
32
+ if (!isSyncCandidate(relPosix, ctx)) {
33
+ dirtyMountPaths.delete(relPosix);
34
+ return 0;
35
+ }
36
+ try {
37
+ const changed = syncOneFile(relPosix, state, ctx);
38
+ dirtyMountPaths.delete(relPosix);
39
+ return changed ? 1 : 0;
40
+ }
41
+ catch (err) {
42
+ onError(err);
43
+ return 0;
44
+ }
45
+ };
46
+ const flushPendingPaths = async (opts) => {
47
+ const signal = opts?.signal;
48
+ if (signal?.aborted) {
49
+ return 0;
50
+ }
51
+ let count = 0;
52
+ let processed = 0;
53
+ for (const relPosix of Array.from(pendingPaths)) {
54
+ if (signal?.aborted) {
55
+ break;
56
+ }
57
+ pendingPaths.delete(relPosix);
58
+ count += syncPath(relPosix);
59
+ processed += 1;
60
+ if (signal && processed % 64 === 0 && !signal.aborted) {
61
+ await new Promise((resolve) => setImmediate(resolve));
62
+ }
63
+ }
64
+ totalChanges += count;
65
+ return count;
66
+ };
15
67
  const runReconcile = async (opts) => {
16
68
  const signal = opts?.signal;
17
69
  if (signal?.aborted) {
@@ -23,8 +75,10 @@ export function startAutoSync(ctx, opts = {}) {
23
75
  }
24
76
  syncing = true;
25
77
  let count = 0;
78
+ let completed = false;
26
79
  try {
27
80
  count = reconcile(state, ctx, onError, signal);
81
+ completed = !signal?.aborted;
28
82
  }
29
83
  catch (err) {
30
84
  onError(err);
@@ -36,53 +90,71 @@ export function startAutoSync(ctx, opts = {}) {
36
90
  pending = false;
37
91
  try {
38
92
  count += reconcile(state, ctx, onError, signal);
93
+ completed = !signal?.aborted;
39
94
  }
40
95
  catch (err) {
41
96
  onError(err);
97
+ completed = false;
42
98
  }
43
99
  }
100
+ if (completed) {
101
+ pendingPaths.clear();
102
+ dirtyMountPaths.clear();
103
+ }
44
104
  totalChanges += count;
45
105
  return count;
46
106
  };
47
- const syncPathFromRoot = (root, absPath) => {
48
- const rel = path.relative(root, absPath);
49
- if (rel === '' || rel.startsWith('..'))
50
- return;
51
- const relPosix = rel.split(path.sep).join('/');
52
- if (!isSyncCandidate(relPosix, ctx))
53
- return;
107
+ const flushPending = async (opts) => {
108
+ if (opts?.signal?.aborted) {
109
+ return 0;
110
+ }
54
111
  try {
55
- const changed = syncOneFile(relPosix, state, ctx);
56
- if (changed)
57
- totalChanges += 1;
112
+ await watchersReady;
58
113
  }
59
- catch (err) {
60
- onError(err);
114
+ catch {
115
+ // Subscription setup failure is already marked degraded and surfaced.
116
+ }
117
+ if (opts?.signal?.aborted) {
118
+ return 0;
61
119
  }
120
+ clearPendingDebounces();
121
+ if (!watchersHealthy()) {
122
+ return runReconcile(opts);
123
+ }
124
+ return flushPendingPaths(opts);
62
125
  };
63
126
  const schedulePathSync = (root, absPath) => {
64
- // Once stop() has begun, refuse to schedule new timers. Watcher
65
- // callbacks can still fire during the unsubscribe await (native
66
- // backends deliver queued events asynchronously); without this guard
67
- // those events would create timers that outlive stop() and do file
68
- // work against a mount the caller may have already cleaned up.
69
127
  if (stopped)
70
128
  return;
129
+ const relPosix = root === ctx.realMountDir
130
+ ? toRelPosix(absPath, ctx)
131
+ : toRelPosixFromProject(absPath, ctx);
132
+ if (relPosix === null || !isSyncCandidate(relPosix, ctx))
133
+ return;
134
+ pendingPaths.add(relPosix);
135
+ if (root === ctx.realMountDir) {
136
+ dirtyMountPaths.add(relPosix);
137
+ }
138
+ // During stop(), keep accepting queued watcher events so the final flush
139
+ // can process them, but don't create timers that could outlive teardown.
140
+ if (stopping)
141
+ return;
71
142
  // Coalesce bursts of events for the same path. The reconcile path
72
143
  // re-checks content via mtime+bytes, so a partial-write event that
73
144
  // races a later write is harmless.
74
- const existing = pendingDebounces.get(absPath);
145
+ const existing = pendingDebounces.get(relPosix);
75
146
  if (existing)
76
147
  clearTimeout(existing);
77
148
  const t = setTimeout(() => {
78
- pendingDebounces.delete(absPath);
79
- syncPathFromRoot(root, absPath);
149
+ pendingDebounces.delete(relPosix);
150
+ pendingPaths.delete(relPosix);
151
+ totalChanges += syncPath(relPosix);
80
152
  }, debounceMs);
81
- pendingDebounces.set(absPath, t);
153
+ pendingDebounces.set(relPosix, t);
82
154
  };
83
155
  const subscribeTo = (root) => watcher.subscribe(root, (err, events) => {
84
156
  if (err) {
85
- onError(err);
157
+ markWatcherDegraded(err);
86
158
  return;
87
159
  }
88
160
  for (const ev of events) {
@@ -105,8 +177,11 @@ export function startAutoSync(ctx, opts = {}) {
105
177
  if (projectResult.status === 'fulfilled')
106
178
  projectSub = projectResult.value;
107
179
  if (mountResult.status === 'fulfilled' && projectResult.status === 'fulfilled') {
180
+ watchersReadySettled = true;
108
181
  return;
109
182
  }
183
+ watchersReadySettled = true;
184
+ watcherDegraded = true;
110
185
  await Promise.allSettled([
111
186
  mountSub?.unsubscribe(),
112
187
  projectSub?.unsubscribe(),
@@ -120,7 +195,7 @@ export function startAutoSync(ctx, opts = {}) {
120
195
  // If subscription setup fails, surface via onError rather than an unhandled
121
196
  // rejection. stop() still awaits the same promise and will observe the
122
197
  // rejection after the cleanup above has already run.
123
- watchersReady.catch((err) => onError(err));
198
+ watchersReady.catch((err) => markWatcherDegraded(err));
124
199
  const interval = setInterval(() => {
125
200
  void runReconcile();
126
201
  }, scanIntervalMs);
@@ -128,10 +203,6 @@ export function startAutoSync(ctx, opts = {}) {
128
203
  interval.unref?.();
129
204
  return {
130
205
  async stop(opts) {
131
- // Flip the flag first so any watcher callbacks delivered during the
132
- // awaits below refuse to schedule new timers.
133
- stopped = true;
134
- clearInterval(interval);
135
206
  try {
136
207
  await watchersReady;
137
208
  }
@@ -139,23 +210,37 @@ export function startAutoSync(ctx, opts = {}) {
139
210
  // Setup failed and already cleaned up any partial subscription;
140
211
  // mountSub / projectSub were reset to undefined before the throw.
141
212
  }
213
+ if (!opts?.signal?.aborted && watchersHealthy()) {
214
+ await new Promise((resolve) => setTimeout(resolve, STOP_EVENT_SETTLE_MS));
215
+ }
216
+ stopping = true;
217
+ clearInterval(interval);
218
+ clearPendingDebounces();
142
219
  await Promise.allSettled([
143
220
  mountSub?.unsubscribe(),
144
221
  projectSub?.unsubscribe(),
145
222
  ]);
146
- // Clear debounces *after* unsubscribe resolves: any timer scheduled
147
- // between stop() being called and the watcher actually quiescing is
148
- // gathered here, so none fire after stop() returns.
149
- for (const t of pendingDebounces.values())
150
- clearTimeout(t);
151
- pendingDebounces.clear();
223
+ clearPendingDebounces();
152
224
  if (opts?.signal?.aborted) {
225
+ stopped = true;
226
+ stopping = false;
153
227
  return;
154
228
  }
155
- // Drain any pending work so callers can rely on "stopped means quiesced".
156
- await runReconcile(opts);
229
+ // Drain pending watcher work when the watcher state is trusted; otherwise
230
+ // keep the historical full-reconcile safety net.
231
+ if (watchersHealthy()) {
232
+ await flushPendingPaths(opts);
233
+ }
234
+ else {
235
+ await runReconcile(opts);
236
+ }
237
+ stopped = true;
238
+ stopping = false;
157
239
  },
240
+ flushPending,
158
241
  reconcile: runReconcile,
242
+ getDirtyPaths: () => new Set(dirtyMountPaths).values(),
243
+ watchersHealthy,
159
244
  totalChanges: () => totalChanges,
160
245
  ready: async () => {
161
246
  await watchersReady;
package/dist/launch.js CHANGED
@@ -11,20 +11,40 @@ export async function launchOnMount(opts) {
11
11
  let syncedCount = 0;
12
12
  let finalized = false;
13
13
  let autoSync;
14
+ let autoSyncReadyBeforeWrites = false;
14
15
  const finalize = async () => {
15
16
  if (finalized)
16
17
  return;
17
18
  finalized = true;
18
19
  try {
19
20
  let autoSyncChanges = 0;
21
+ let finalSyncBackPaths;
20
22
  if (autoSync) {
21
23
  await autoSync.stop({ signal: opts.shutdownSignal });
22
24
  autoSyncChanges = autoSync.totalChanges();
25
+ if (autoSyncReadyBeforeWrites && autoSync.watchersHealthy()) {
26
+ const dirty = Array.from(autoSync.getDirtyPaths());
27
+ // Only take the fast path when there's at least one dirty
28
+ // path to sync. An empty dirty set is ambiguous after
29
+ // autoSync.stop() — the healthy-path flush already drained
30
+ // pending events, so the set could be empty either because
31
+ // nothing changed or because edits raced past the watcher
32
+ // (short-lived runs where the watcher hadn't enqueued
33
+ // anything before stop). Fall through to the full walk in
34
+ // that case as a safety net; it's cheap on no-change runs
35
+ // and prevents dropped mount edits at shutdown.
36
+ if (dirty.length > 0) {
37
+ finalSyncBackPaths = dirty;
38
+ }
39
+ }
23
40
  autoSync = undefined;
24
41
  }
25
42
  if (!handle)
26
43
  return;
27
- const finalSynced = await handle.syncBack({ signal: opts.shutdownSignal });
44
+ const finalSynced = await handle.syncBack({
45
+ signal: opts.shutdownSignal,
46
+ ...(finalSyncBackPaths ? { paths: finalSyncBackPaths } : {}),
47
+ });
28
48
  syncedCount = autoSyncChanges + finalSynced;
29
49
  if (opts.onAfterSync) {
30
50
  await opts.onAfterSync(syncedCount);
@@ -43,12 +63,21 @@ export async function launchOnMount(opts) {
43
63
  includeGit: opts.includeGit,
44
64
  includeDefaultExcludeDirs: opts.includeDefaultExcludeDirs,
45
65
  });
46
- if (opts.onBeforeLaunch) {
47
- await opts.onBeforeLaunch(handle.mountDir);
48
- }
49
66
  if (opts.autoSync !== false) {
50
67
  const autoSyncOpts = typeof opts.autoSync === 'object' ? opts.autoSync : undefined;
51
68
  autoSync = handle.startAutoSync(autoSyncOpts);
69
+ try {
70
+ await autoSync.ready();
71
+ autoSyncReadyBeforeWrites = autoSync.watchersHealthy();
72
+ }
73
+ catch {
74
+ // Keep launching with degraded auto-sync; shutdown will use the full
75
+ // syncBack sweep because dirty-path state was never trustworthy.
76
+ autoSyncReadyBeforeWrites = false;
77
+ }
78
+ }
79
+ if (opts.onBeforeLaunch) {
80
+ await opts.onBeforeLaunch(handle.mountDir);
52
81
  }
53
82
  const envVars = {
54
83
  ...process.env,
package/dist/mount.d.ts CHANGED
@@ -32,6 +32,7 @@ export interface MountHandle {
32
32
  mountDir: string;
33
33
  syncBack(opts?: {
34
34
  signal?: AbortSignal;
35
+ paths?: Iterable<string>;
35
36
  }): Promise<number>;
36
37
  /**
37
38
  * Start bidirectional auto-sync: watches both the mount and project trees
package/dist/mount.js CHANGED
@@ -83,7 +83,9 @@ export async function createMount(projectDir, mountDir, options) {
83
83
  let synced = 0;
84
84
  const realProjectDir = realpathSync(resolvedProjectDir);
85
85
  const realMountDir = realpathSync(resolvedMountDir);
86
- const files = listFiles(realMountDir);
86
+ const files = opts?.paths
87
+ ? syncBackPathsToFiles(realMountDir, opts.paths)
88
+ : listFiles(realMountDir);
87
89
  const signal = opts?.signal;
88
90
  for (const sourceFile of files) {
89
91
  if (signal?.aborted) {
@@ -92,7 +94,14 @@ export async function createMount(projectDir, mountDir, options) {
92
94
  const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher, noSyncBackMatcher, (relPosix) => isExcludedPath(relPosix, excludeRules));
93
95
  synced += syncedForFile;
94
96
  if (signal && syncedForFile > 0 && !signal.aborted) {
95
- await new Promise((resolve) => setImmediate(resolve));
97
+ // Intentionally uses setTimeout(resolve, 0) rather than the
98
+ // setImmediate-based yieldToEventLoop helper below: aborts
99
+ // mid-walk are scheduled via setTimeout (see mount.test.ts
100
+ // "syncBack: returns a partial count when aborted mid-walk"),
101
+ // and matching the same queue makes the abort observable
102
+ // between file syncs. setImmediate runs after timer
103
+ // callbacks in a single I/O cycle and races the abort.
104
+ await new Promise((resolve) => setTimeout(resolve, 0));
96
105
  }
97
106
  }
98
107
  return synced;
@@ -260,6 +269,27 @@ function listFiles(baseDir) {
260
269
  }
261
270
  return files;
262
271
  }
272
+ function syncBackPathsToFiles(mountDir, relPaths) {
273
+ const files = [];
274
+ const seen = new Set();
275
+ for (const relPath of relPaths) {
276
+ const sourceFile = resolveSyncBackSource(mountDir, relPath);
277
+ if (!sourceFile || seen.has(sourceFile)) {
278
+ continue;
279
+ }
280
+ seen.add(sourceFile);
281
+ files.push(sourceFile);
282
+ }
283
+ return files;
284
+ }
285
+ function resolveSyncBackSource(mountDir, relPath) {
286
+ const normalized = normalizeRelativePosix(relPath);
287
+ if (!normalized || path.isAbsolute(normalized)) {
288
+ return null;
289
+ }
290
+ const candidate = path.resolve(mountDir, ...normalized.split('/').filter(Boolean));
291
+ return isPathWithinRoot(candidate, mountDir) ? candidate : null;
292
+ }
263
293
  function normalizeRelativePosix(filePath) {
264
294
  return filePath.split(path.sep).join('/');
265
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "description": "Create a symlink/copy mount of a project directory with .agentignore/.agentreadonly semantics, then launch a CLI inside it and sync writable changes back on exit",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",