@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 +12 -3
- package/dist/auto-sync.d.ts +6 -2
- package/dist/auto-sync.js +31 -10
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +2 -2
- package/dist/symlink-mount.d.ts +3 -1
- package/dist/symlink-mount.js +10 -2
- package/package.json +1 -1
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`:
|
package/dist/auto-sync.d.ts
CHANGED
|
@@ -24,9 +24,13 @@ export interface AutoSyncOptions {
|
|
|
24
24
|
onError?: (err: Error) => void;
|
|
25
25
|
}
|
|
26
26
|
export interface AutoSyncHandle {
|
|
27
|
-
stop(
|
|
27
|
+
stop(opts?: {
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}): Promise<void>;
|
|
28
30
|
/** Force a reconcile now; returns number of files copied/deleted. */
|
|
29
|
-
reconcile(
|
|
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);
|
package/dist/symlink-mount.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ export interface SymlinkMountOptions {
|
|
|
11
11
|
}
|
|
12
12
|
export interface SymlinkMountHandle {
|
|
13
13
|
mountDir: string;
|
|
14
|
-
syncBack(
|
|
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`
|
package/dist/symlink-mount.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|