@relayfile/local-mount 0.4.0 → 0.5.1

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
@@ -19,7 +19,7 @@ Builds a mounted copy of `projectDir` at `mountDir` and returns a handle:
19
19
  ```ts
20
20
  interface SymlinkMountHandle {
21
21
  mountDir: string;
22
- syncBack(): Promise<number>;
22
+ syncBack(opts?: { signal?: AbortSignal }): Promise<number>;
23
23
  startAutoSync(opts?: AutoSyncOptions): AutoSyncHandle;
24
24
  cleanup(): void;
25
25
  }
@@ -62,6 +62,8 @@ High-level helper that:
62
62
 
63
63
  It resolves with the child process exit code. `onAfterSync(count)` receives the sum of files changed by auto-sync plus the final sync-back pass.
64
64
 
65
+ `launchOnMount({ shutdownSignal })` threads an optional `AbortSignal` into the shutdown phase only. It does not cancel the spawned CLI. If shutdown is aborted, `onAfterSync` still fires with the partial count gathered so far and the mount directory is still cleaned up.
66
+
65
67
  ### Auto-sync
66
68
 
67
69
  By default, `launchOnMount` keeps the mount and project directory in sync continuously while the CLI is running, rather than only at exit. The same machinery is available standalone via `handle.startAutoSync()`.
@@ -77,8 +79,8 @@ interface AutoSyncOptions {
77
79
  }
78
80
 
79
81
  interface AutoSyncHandle {
80
- stop(): Promise<void>;
81
- reconcile(): Promise<number>;
82
+ stop(opts?: { signal?: AbortSignal }): Promise<void>;
83
+ reconcile(opts?: { signal?: AbortSignal }): Promise<number>;
82
84
  totalChanges(): number;
83
85
  ready(): Promise<void>;
84
86
  }
@@ -97,6 +99,7 @@ launchOnMount({ /* ... */, autoSync: { scanIntervalMs: 5_000, debounceMs: 100 }
97
99
  How it works:
98
100
  - [@parcel/watcher](https://www.npmjs.com/package/@parcel/watcher) watches both the mount and the project tree using native FSEvents/inotify/ReadDirectoryChangesW
99
101
  - every `scanIntervalMs`, a full reconcile walks both trees as a safety net for missed events
102
+ - `stop({ signal })` still closes watchers if aborted, but skips the final draining reconcile
100
103
  - per-file `mtime` is tracked at the last sync, so the scan skips files that haven't changed
101
104
 
102
105
  Conflict and delete rules:
@@ -154,6 +157,7 @@ import os from 'node:os';
154
157
 
155
158
  const projectDir = '/projects/acme-api';
156
159
  const mountDir = path.join(os.tmpdir(), 'acme-api-agent-mount');
160
+ const abortController = new AbortController();
157
161
 
158
162
  const { ignoredPatterns, readonlyPatterns } = readAgentDotfiles(projectDir, {
159
163
  agentName: 'reviewer',
@@ -172,6 +176,7 @@ const result = await launchOnMount({
172
176
  // Add extra instructions or scratch files inside the mount if needed.
173
177
  console.log(`Mount ready at ${dir}`);
174
178
  },
179
+ shutdownSignal: abortController.signal,
175
180
  onAfterSync: async (count) => {
176
181
  console.log(`Synced ${count} writable file(s) back to the project`);
177
182
  },
@@ -193,6 +198,10 @@ console.log(result.exitCode);
193
198
 
194
199
  The returned number is the count of files written back to `projectDir` in that pass. `syncBack()` never deletes — delete propagation is handled by auto-sync.
195
200
 
201
+ `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.
202
+
203
+ `reconcile({ signal })` and the internal tree walk also poll for aborts between file visits so an in-flight draining scan can stop cooperatively.
204
+
196
205
  ## Safety constraints
197
206
 
198
207
  The implementation is intentionally conservative about `mountDir`:
@@ -24,9 +24,13 @@ export interface AutoSyncOptions {
24
24
  onError?: (err: Error) => void;
25
25
  }
26
26
  export interface AutoSyncHandle {
27
- stop(): Promise<void>;
27
+ stop(opts?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<void>;
28
30
  /** Force a reconcile now; returns number of files copied/deleted. */
29
- reconcile(): Promise<number>;
31
+ reconcile(opts?: {
32
+ signal?: AbortSignal;
33
+ }): Promise<number>;
30
34
  /** Cumulative files changed (copied or deleted) since autosync started. */
31
35
  totalChanges(): number;
32
36
  /** Resolves once both watchers have completed their initial scan. */
package/dist/auto-sync.js CHANGED
@@ -12,7 +12,11 @@ export function startAutoSync(ctx, opts = {}) {
12
12
  let stopped = false;
13
13
  let totalChanges = 0;
14
14
  const pendingDebounces = new Map();
15
- const runReconcile = async () => {
15
+ const runReconcile = async (opts) => {
16
+ const signal = opts?.signal;
17
+ if (signal?.aborted) {
18
+ return 0;
19
+ }
16
20
  if (syncing) {
17
21
  pending = true;
18
22
  return 0;
@@ -20,7 +24,7 @@ export function startAutoSync(ctx, opts = {}) {
20
24
  syncing = true;
21
25
  let count = 0;
22
26
  try {
23
- count = reconcile(state, ctx, onError);
27
+ count = reconcile(state, ctx, onError, signal);
24
28
  }
25
29
  catch (err) {
26
30
  onError(err);
@@ -28,10 +32,10 @@ export function startAutoSync(ctx, opts = {}) {
28
32
  finally {
29
33
  syncing = false;
30
34
  }
31
- if (pending) {
35
+ if (pending && !signal?.aborted) {
32
36
  pending = false;
33
37
  try {
34
- count += reconcile(state, ctx, onError);
38
+ count += reconcile(state, ctx, onError, signal);
35
39
  }
36
40
  catch (err) {
37
41
  onError(err);
@@ -124,7 +128,7 @@ export function startAutoSync(ctx, opts = {}) {
124
128
  // Do not keep the event loop alive just because of our scan timer.
125
129
  interval.unref?.();
126
130
  return {
127
- async stop() {
131
+ async stop(opts) {
128
132
  // Flip the flag first so any watcher callbacks delivered during the
129
133
  // awaits below refuse to schedule new timers.
130
134
  stopped = true;
@@ -146,8 +150,11 @@ export function startAutoSync(ctx, opts = {}) {
146
150
  for (const t of pendingDebounces.values())
147
151
  clearTimeout(t);
148
152
  pendingDebounces.clear();
153
+ if (opts?.signal?.aborted) {
154
+ return;
155
+ }
149
156
  // Drain any pending work so callers can rely on "stopped means quiesced".
150
- await runReconcile();
157
+ await runReconcile(opts);
151
158
  },
152
159
  reconcile: runReconcile,
153
160
  totalChanges: () => totalChanges,
@@ -198,7 +205,7 @@ function primeState(state, ctx) {
198
205
  });
199
206
  });
200
207
  }
201
- function reconcile(state, ctx, onError) {
208
+ function reconcile(state, ctx, onError, signal) {
202
209
  const seen = new Set();
203
210
  let count = 0;
204
211
  const visit = (relPosix) => {
@@ -217,15 +224,25 @@ function reconcile(state, ctx, onError) {
217
224
  }
218
225
  };
219
226
  walk(ctx.realMountDir, ctx, (abs) => {
227
+ if (signal?.aborted)
228
+ return;
220
229
  const rel = toRelPosix(abs, ctx);
221
230
  if (rel !== null)
222
231
  visit(rel);
223
- });
232
+ }, signal);
233
+ if (signal?.aborted) {
234
+ return count;
235
+ }
224
236
  walk(ctx.realProjectDir, ctx, (abs) => {
237
+ if (signal?.aborted)
238
+ return;
225
239
  const rel = toRelPosixFromProject(abs, ctx);
226
240
  if (rel !== null)
227
241
  visit(rel);
228
- });
242
+ }, signal);
243
+ if (signal?.aborted) {
244
+ return count;
245
+ }
229
246
  // Tombstone sweep: any path in state we didn't visit had both sides absent,
230
247
  // so it's fully gone.
231
248
  for (const rel of Array.from(state.keys())) {
@@ -488,9 +505,11 @@ function resolveSafeWriteTarget(root, candidate) {
488
505
  return null;
489
506
  }
490
507
  }
491
- function walk(root, ctx, visit) {
508
+ function walk(root, ctx, visit, signal) {
492
509
  const stack = [root];
493
510
  while (stack.length > 0) {
511
+ if (signal?.aborted)
512
+ return;
494
513
  const cur = stack.pop();
495
514
  if (!cur)
496
515
  continue;
@@ -502,6 +521,8 @@ function walk(root, ctx, visit) {
502
521
  continue;
503
522
  }
504
523
  for (const entry of entries) {
524
+ if (signal?.aborted)
525
+ return;
505
526
  const abs = path.join(cur, entry.name);
506
527
  const rel = path.relative(root, abs).split(path.sep).join('/');
507
528
  if (!rel || rel.startsWith('..'))
package/dist/launch.d.ts CHANGED
@@ -18,6 +18,14 @@ export interface LaunchOnMountOptions {
18
18
  env?: NodeJS.ProcessEnv;
19
19
  /** Optional agent name, used in the _MOUNT_README.md "Agent:" line. */
20
20
  agentName?: string;
21
+ /**
22
+ * Optional signal used during shutdown work after the child exits.
23
+ * It is passed to both autosync shutdown and the final mount→project sync-back.
24
+ * If aborted, autosync stop may skip its draining reconcile and sync-back
25
+ * returns the partial count accumulated so far, so fewer changes may be
26
+ * propagated than during an uninterrupted shutdown.
27
+ */
28
+ shutdownSignal?: AbortSignal;
21
29
  /**
22
30
  * Invoked after the mount is created but before the CLI is spawned.
23
31
  * Useful for writing additional files into the mount (overrides, extra docs).
package/dist/launch.js CHANGED
@@ -23,11 +23,11 @@ export async function launchOnMount(opts) {
23
23
  try {
24
24
  let autoSyncChanges = 0;
25
25
  if (autoSync) {
26
- await autoSync.stop();
26
+ await autoSync.stop({ signal: opts.shutdownSignal });
27
27
  autoSyncChanges = autoSync.totalChanges();
28
28
  autoSync = undefined;
29
29
  }
30
- const finalSynced = await handle.syncBack();
30
+ const finalSynced = await handle.syncBack({ signal: opts.shutdownSignal });
31
31
  syncedCount = autoSyncChanges + finalSynced;
32
32
  if (opts.onAfterSync) {
33
33
  await opts.onAfterSync(syncedCount);
@@ -11,7 +11,9 @@ export interface SymlinkMountOptions {
11
11
  }
12
12
  export interface SymlinkMountHandle {
13
13
  mountDir: string;
14
- syncBack(): Promise<number>;
14
+ syncBack(opts?: {
15
+ signal?: AbortSignal;
16
+ }): Promise<number>;
15
17
  /**
16
18
  * Start bidirectional auto-sync: watches both the mount and project trees
17
19
  * via @parcel/watcher and runs a full reconcile every `scanIntervalMs`
@@ -46,13 +46,21 @@ export function createSymlinkMount(projectDir, mountDir, options) {
46
46
  };
47
47
  return {
48
48
  mountDir: resolvedMountDir,
49
- async syncBack() {
49
+ async syncBack(opts) {
50
50
  let synced = 0;
51
51
  const realProjectDir = realpathSync(resolvedProjectDir);
52
52
  const realMountDir = realpathSync(resolvedMountDir);
53
53
  const files = listFiles(realMountDir);
54
+ const signal = opts?.signal;
54
55
  for (const sourceFile of files) {
55
- synced += syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
56
+ if (signal?.aborted) {
57
+ break;
58
+ }
59
+ const syncedForFile = syncMountedFileBack(sourceFile, realMountDir, realProjectDir, readonlyMatcher, ignoredMatcher);
60
+ synced += syncedForFile;
61
+ if (signal && syncedForFile > 0 && !signal.aborted) {
62
+ await new Promise((resolve) => setImmediate(resolve));
63
+ }
56
64
  }
57
65
  return synced;
58
66
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayfile/local-mount",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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",