talon-agent 1.53.0 → 2.0.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 +1 -1
- package/package.json +3 -2
- package/src/app.ts +11 -0
- package/src/cli/index.ts +0 -15
- package/src/core/doctor.ts +20 -0
- package/src/core/engine/gateway-actions/index.ts +0 -3
- package/src/core/engine/gateway-actions/native.ts +108 -100
- package/src/core/engine/gateway.ts +0 -28
- package/src/core/tools/index.ts +0 -2
- package/src/core/tools/native.ts +16 -8
- package/src/core/tools/types.ts +1 -2
- package/src/core/vfs/fusefs.ts +438 -0
- package/src/core/vfs/index.ts +21 -2
- package/src/core/vfs/nsdir.ts +95 -0
- package/src/core/vfs/rewrite.ts +170 -0
- package/src/core/vfs/vfs.ts +34 -19
- package/src/native/fusefs.ts +97 -0
- package/src/util/config.ts +8 -0
- package/src/util/log.ts +1 -0
- package/src/util/paths.ts +7 -0
- package/src/cli/fs.ts +0 -138
- package/src/core/engine/gateway-actions/vfs.ts +0 -69
- package/src/core/tools/vfs.ts +0 -61
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FUSE layer lifecycle — mounts the talon:// namespace at ~/.talon/ns
|
|
3
|
+
* while the daemon runs.
|
|
4
|
+
*
|
|
5
|
+
* The mount serves symlinks for file-backed mounts (kernel-followed, so
|
|
6
|
+
* heavy I/O never crosses FUSE) and answers synthetic subtrees (proc/,
|
|
7
|
+
* plugins/) live from the JS Vfs over the addon's callback bridge. When
|
|
8
|
+
* anything is missing — config off, addon absent, no /dev/fuse, mount
|
|
9
|
+
* or sanity check fails — the layer degrades to the plain symlink farm
|
|
10
|
+
* and records why: fuseless hosts get the identical experience minus
|
|
11
|
+
* live views, never an error at boot.
|
|
12
|
+
*
|
|
13
|
+
* The degradation is not just a boot-time decision. Once mounted, a
|
|
14
|
+
* health watchdog re-probes the live views on an interval; if the mount
|
|
15
|
+
* goes unhealthy at runtime — the classic case being a `bin/*.node`
|
|
16
|
+
* rebuilt out from under the running daemon, or the mountpoint wedged to
|
|
17
|
+
* ENOTCONN — it tears the dead mount down, restores the symlink farm so
|
|
18
|
+
* file-backed paths keep working, and tries a bounded number of
|
|
19
|
+
* reconnects. A mount that can't come back settles into fuseless for
|
|
20
|
+
* the rest of the run. A broken FUSE layer must never take the daemon
|
|
21
|
+
* down with it; the worst outcome is losing the live views.
|
|
22
|
+
*
|
|
23
|
+
* Deadlock rule: the daemon must never do SYNCHRONOUS fs I/O under
|
|
24
|
+
* ~/.talon/ns — a sync call blocks the one JS thread that answers the
|
|
25
|
+
* bridge, wedging both sides. Async fs is safe (libuv worker blocks,
|
|
26
|
+
* the event loop answers), and child/external processes are always
|
|
27
|
+
* safe. The bridge handler itself only touches in-memory synthetic
|
|
28
|
+
* mounts, keeping replies non-blocking by construction.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { spawnSync } from "node:child_process";
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
import { readdir, stat } from "node:fs/promises";
|
|
34
|
+
import { log, logWarn } from "../../util/log.js";
|
|
35
|
+
import { dirs } from "../../util/paths.js";
|
|
36
|
+
import type { NativeFuseFs } from "../../native/fusefs.js";
|
|
37
|
+
import { nativeFuseFs } from "../../native/fusefs.js";
|
|
38
|
+
import { syncNamespaceDir } from "./nsdir.js";
|
|
39
|
+
import type { VfsErrorCode } from "./types.js";
|
|
40
|
+
import type { Vfs } from "./vfs.js";
|
|
41
|
+
|
|
42
|
+
export type FuseStatus = {
|
|
43
|
+
readonly mounted: boolean;
|
|
44
|
+
/** Why the layer is off — set exactly when `mounted` is false. */
|
|
45
|
+
readonly reason?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** How long a post-mount sanity probe may take before we bail out. */
|
|
49
|
+
const SANITY_TIMEOUT_MS = 3_000;
|
|
50
|
+
|
|
51
|
+
/** How often the health watchdog re-probes a live mount. */
|
|
52
|
+
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How many times the watchdog will try to remount after the mount goes
|
|
56
|
+
* unhealthy before giving up and staying fuseless for the rest of the
|
|
57
|
+
* run. Bounded so a permanently-broken FUSE setup doesn't thrash
|
|
58
|
+
* remount attempts every interval forever.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
61
|
+
|
|
62
|
+
let status: FuseStatus = { mounted: false, reason: "not started" };
|
|
63
|
+
let activeAddon: NativeFuseFs | null = null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Everything the watchdog needs to re-probe and reconnect the mount,
|
|
67
|
+
* captured on the last successful mount. `null` until then and after a
|
|
68
|
+
* clean shutdown — the watchdog is a no-op without it.
|
|
69
|
+
*/
|
|
70
|
+
type LiveMount = {
|
|
71
|
+
readonly mode: "auto" | "off";
|
|
72
|
+
readonly vfs: Vfs;
|
|
73
|
+
readonly nsRoot: string;
|
|
74
|
+
readonly addon: NativeFuseFs;
|
|
75
|
+
/** First synthetic mount — what the probe reads to prove liveness. */
|
|
76
|
+
readonly probe: string | undefined;
|
|
77
|
+
};
|
|
78
|
+
let live: LiveMount | null = null;
|
|
79
|
+
let healthTimer: ReturnType<typeof setInterval> | null = null;
|
|
80
|
+
let healing = false;
|
|
81
|
+
let reconnectAttempts = 0;
|
|
82
|
+
|
|
83
|
+
/** Is the FUSE layer live? Consulted by the address/command resolvers. */
|
|
84
|
+
export function isNamespaceFsMounted(): boolean {
|
|
85
|
+
return status.mounted;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Current layer status, for doctor/status surfaces. */
|
|
89
|
+
export function namespaceFsStatus(): FuseStatus {
|
|
90
|
+
return status;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Test seam — force a status without a real mount. */
|
|
94
|
+
export function _setNamespaceFsStatusForTesting(next: FuseStatus): void {
|
|
95
|
+
status = next;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Test seam — drive one watchdog tick synchronously (no interval wait). */
|
|
99
|
+
export async function _checkNamespaceFsHealthForTesting(): Promise<void> {
|
|
100
|
+
await checkNamespaceFsHealth();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Test seam — the watchdog's current reconnect-attempt counter. */
|
|
104
|
+
export function _reconnectAttemptsForTesting(): number {
|
|
105
|
+
return reconnectAttempts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Bridge: FUSE thread → JS Vfs ─────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
const ERRNO: Record<VfsErrorCode, string> = {
|
|
111
|
+
"not-found": "ENOENT",
|
|
112
|
+
"invalid-path": "ENOENT",
|
|
113
|
+
"is-a-directory": "EISDIR",
|
|
114
|
+
"not-a-directory": "ENOTDIR",
|
|
115
|
+
"not-writable": "EROFS",
|
|
116
|
+
"too-large": "EFBIG",
|
|
117
|
+
"binary-file": "EIO",
|
|
118
|
+
"io-error": "EIO",
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Answer one bridge request. Pure Vfs → JSON: `path` is
|
|
123
|
+
* namespace-relative ("proc/tasks/7"); ops are stat / list / read.
|
|
124
|
+
* Never throws — every failure becomes an errno reply, because an
|
|
125
|
+
* unanswered request strands a kernel caller until the addon times it
|
|
126
|
+
* out.
|
|
127
|
+
*/
|
|
128
|
+
export function serveNamespaceRequest(
|
|
129
|
+
vfs: Vfs,
|
|
130
|
+
op: string,
|
|
131
|
+
path: string,
|
|
132
|
+
): string {
|
|
133
|
+
try {
|
|
134
|
+
const address = `talon://${path}`;
|
|
135
|
+
if (op === "stat") {
|
|
136
|
+
const result = vfs.stat(address);
|
|
137
|
+
if (!result.ok) return errno(result.error);
|
|
138
|
+
return JSON.stringify({
|
|
139
|
+
ok: true,
|
|
140
|
+
kind: result.value.kind,
|
|
141
|
+
size: result.value.size ?? 0,
|
|
142
|
+
mtimeMs: result.value.modifiedAt ?? 0,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (op === "list") {
|
|
146
|
+
const result = vfs.list(address);
|
|
147
|
+
if (!result.ok) return errno(result.error);
|
|
148
|
+
return JSON.stringify({
|
|
149
|
+
ok: true,
|
|
150
|
+
entries: result.value.map((entry) => ({
|
|
151
|
+
name: entry.name,
|
|
152
|
+
kind: entry.kind,
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (op === "read") {
|
|
157
|
+
const result = vfs.read(address);
|
|
158
|
+
if (!result.ok) return errno(result.error);
|
|
159
|
+
return JSON.stringify({
|
|
160
|
+
ok: true,
|
|
161
|
+
data: Buffer.from(result.value, "utf-8").toString("base64"),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return errno("io-error");
|
|
165
|
+
} catch {
|
|
166
|
+
return JSON.stringify({ ok: false, errno: "EIO" });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function errno(code: VfsErrorCode): string {
|
|
171
|
+
return JSON.stringify({ ok: false, errno: ERRNO[code] });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── Mount lifecycle ──────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export interface MountOptions {
|
|
177
|
+
/** `config.fuse` — "auto" mounts when possible, "off" never mounts. */
|
|
178
|
+
mode: "auto" | "off";
|
|
179
|
+
vfs: Vfs;
|
|
180
|
+
/** Test seams; production callers omit them. */
|
|
181
|
+
nsRoot?: string;
|
|
182
|
+
addon?: NativeFuseFs | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Bring the FUSE layer up if this host can. Never throws: every failure
|
|
187
|
+
* path lands in `{ mounted: false, reason }` with the symlink farm
|
|
188
|
+
* already synced, so the fuseless experience is intact regardless.
|
|
189
|
+
*/
|
|
190
|
+
export async function mountNamespaceFs(
|
|
191
|
+
options: MountOptions,
|
|
192
|
+
): Promise<FuseStatus> {
|
|
193
|
+
const nsRoot = options.nsRoot ?? dirs.ns;
|
|
194
|
+
await recoverStaleMount(nsRoot);
|
|
195
|
+
try {
|
|
196
|
+
syncNamespaceDir(options.vfs, nsRoot);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return down(`namespace dir sync failed: ${message(err)}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (options.mode === "off") return down('disabled (config fuse: "off")');
|
|
202
|
+
if (process.platform !== "linux") {
|
|
203
|
+
return down("FUSE layer is Linux-only for now");
|
|
204
|
+
}
|
|
205
|
+
if (!existsSync("/dev/fuse")) return down("/dev/fuse not present");
|
|
206
|
+
|
|
207
|
+
const addon = options.addon !== undefined ? options.addon : nativeFuseFs();
|
|
208
|
+
if (!addon) return down("talon-fusefs addon not available");
|
|
209
|
+
|
|
210
|
+
const synthetic = options.vfs
|
|
211
|
+
.describeMounts()
|
|
212
|
+
.filter((mount) => mount.osRoot === undefined)
|
|
213
|
+
.map((mount) => mount.name);
|
|
214
|
+
const symlinks = options.vfs
|
|
215
|
+
.describeMounts()
|
|
216
|
+
.filter((mount) => mount.osRoot !== undefined)
|
|
217
|
+
.map((mount) => ({ name: mount.name, target: mount.osRoot! }));
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
addon.mount(nsRoot, symlinks, synthetic, (id, op, path) => {
|
|
221
|
+
addon.reply(id, serveNamespaceRequest(options.vfs, op, path));
|
|
222
|
+
});
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return down(`mount failed: ${message(err)}`);
|
|
225
|
+
}
|
|
226
|
+
activeAddon = addon;
|
|
227
|
+
|
|
228
|
+
// Sanity: the mount must actually answer before we advertise it.
|
|
229
|
+
// Async fs only — see the deadlock rule in the module doc.
|
|
230
|
+
const probe = synthetic[0];
|
|
231
|
+
const healthy = await probeMount(nsRoot, probe);
|
|
232
|
+
if (healthy !== true) {
|
|
233
|
+
await teardownAddon();
|
|
234
|
+
// The failed mount may have shadowed the symlink farm — restore it.
|
|
235
|
+
try {
|
|
236
|
+
syncNamespaceDir(options.vfs, nsRoot);
|
|
237
|
+
} catch {
|
|
238
|
+
// best effort; the boot log already carries the real failure
|
|
239
|
+
}
|
|
240
|
+
return down(
|
|
241
|
+
healthy === false
|
|
242
|
+
? "mount sanity check failed (synthetic mounts not visible)"
|
|
243
|
+
: `mount sanity check timed out after ${SANITY_TIMEOUT_MS}ms`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
status = { mounted: true };
|
|
248
|
+
live = { mode: options.mode, vfs: options.vfs, nsRoot, addon, probe };
|
|
249
|
+
reconnectAttempts = 0;
|
|
250
|
+
startHealthWatchdog();
|
|
251
|
+
log("fusefs", `talon:// namespace mounted at ${nsRoot}`);
|
|
252
|
+
return status;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Probe a live mount: read the root and confirm the first synthetic
|
|
257
|
+
* subtree is a directory the bridge answers. `false` = mounted but not
|
|
258
|
+
* serving live views, `"timeout"` = the mount hangs syscalls (a wedged
|
|
259
|
+
* predecessor). Async fs only — a sync call under nsRoot would block the
|
|
260
|
+
* one JS thread that answers the bridge (see the deadlock rule).
|
|
261
|
+
*/
|
|
262
|
+
async function probeMount(
|
|
263
|
+
nsRoot: string,
|
|
264
|
+
probe: string | undefined,
|
|
265
|
+
): Promise<boolean | "timeout"> {
|
|
266
|
+
return withTimeout(
|
|
267
|
+
(async () => {
|
|
268
|
+
const entries = await readdir(nsRoot);
|
|
269
|
+
if (probe !== undefined && !entries.includes(probe)) return false;
|
|
270
|
+
if (probe !== undefined) {
|
|
271
|
+
if (!(await stat(`${nsRoot}/${probe}`)).isDirectory()) return false;
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
})(),
|
|
275
|
+
SANITY_TIMEOUT_MS,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Tear the addon's mount down without touching the watchdog. Shared by
|
|
281
|
+
* the sanity-rollback path, the self-heal path, and public unmount.
|
|
282
|
+
* Never throws, idempotent.
|
|
283
|
+
*/
|
|
284
|
+
async function teardownAddon(): Promise<void> {
|
|
285
|
+
const addon = activeAddon;
|
|
286
|
+
activeAddon = null;
|
|
287
|
+
if (status.mounted) status = { mounted: false, reason: "unmounted" };
|
|
288
|
+
if (!addon) return;
|
|
289
|
+
try {
|
|
290
|
+
addon.unmount();
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logWarn("fusefs", `unmount failed: ${message(err)}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Tear the layer down for good (daemon shutdown). Stops the watchdog so
|
|
298
|
+
* it can't resurrect the mount mid-shutdown, then unmounts. Never
|
|
299
|
+
* throws, idempotent.
|
|
300
|
+
*/
|
|
301
|
+
export async function unmountNamespaceFs(): Promise<void> {
|
|
302
|
+
stopHealthWatchdog();
|
|
303
|
+
live = null;
|
|
304
|
+
await teardownAddon();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Health watchdog: reconnect or degrade, never die ─────────────────────────
|
|
308
|
+
|
|
309
|
+
function startHealthWatchdog(): void {
|
|
310
|
+
if (healthTimer) return;
|
|
311
|
+
healthTimer = setInterval(() => {
|
|
312
|
+
void checkNamespaceFsHealth();
|
|
313
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
314
|
+
// The daemon owns the lifecycle; the probe must not keep the loop alive.
|
|
315
|
+
healthTimer.unref();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function stopHealthWatchdog(): void {
|
|
319
|
+
if (healthTimer) {
|
|
320
|
+
clearInterval(healthTimer);
|
|
321
|
+
healthTimer = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* One watchdog tick. When the live mount stops answering, drop to
|
|
327
|
+
* fuseless immediately (the safe state — file-backed paths keep working
|
|
328
|
+
* via the symlink farm) and then try to reconnect, bounded. Runs to
|
|
329
|
+
* completion under a re-entrancy guard so overlapping ticks can't stack
|
|
330
|
+
* a second teardown/remount on top of one in flight. Never throws.
|
|
331
|
+
*/
|
|
332
|
+
async function checkNamespaceFsHealth(): Promise<void> {
|
|
333
|
+
// Snapshot the mount facts: a concurrent shutdown nulls `live` between
|
|
334
|
+
// our awaits, and a tick must finish against one consistent view.
|
|
335
|
+
const mount = live;
|
|
336
|
+
if (healing || mount === null) return;
|
|
337
|
+
healing = true;
|
|
338
|
+
try {
|
|
339
|
+
if (status.mounted) {
|
|
340
|
+
const healthy = await probeMount(mount.nsRoot, mount.probe);
|
|
341
|
+
if (healthy === true) return;
|
|
342
|
+
logWarn(
|
|
343
|
+
"fusefs",
|
|
344
|
+
`mount went unhealthy (${
|
|
345
|
+
healthy === false ? "live views vanished" : "probe timed out"
|
|
346
|
+
}) — degrading to fuseless${
|
|
347
|
+
reconnectAttempts < MAX_RECONNECT_ATTEMPTS ? " and reconnecting" : ""
|
|
348
|
+
}`,
|
|
349
|
+
);
|
|
350
|
+
await teardownAddon();
|
|
351
|
+
// The dead mount may still shadow the mountpoint — restore the farm
|
|
352
|
+
// so file-backed paths survive the fuseless window.
|
|
353
|
+
try {
|
|
354
|
+
syncNamespaceDir(mount.vfs, mount.nsRoot);
|
|
355
|
+
} catch {
|
|
356
|
+
// best effort; the warning above already carries the real failure
|
|
357
|
+
}
|
|
358
|
+
status = {
|
|
359
|
+
mounted: false,
|
|
360
|
+
reason: "mount went unhealthy — degraded to fuseless",
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Shutdown raced this tick — nothing to reconnect to.
|
|
365
|
+
if (live === null) return;
|
|
366
|
+
|
|
367
|
+
// Already fuseless (just degraded, or a prior reconnect failed): try
|
|
368
|
+
// to bring the mount back, up to the cap.
|
|
369
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
370
|
+
stopHealthWatchdog();
|
|
371
|
+
status = {
|
|
372
|
+
mounted: false,
|
|
373
|
+
reason: `mount unhealthy; gave up after ${MAX_RECONNECT_ATTEMPTS} reconnect attempts — staying fuseless`,
|
|
374
|
+
};
|
|
375
|
+
logWarn("fusefs", status.reason!);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
reconnectAttempts += 1;
|
|
379
|
+
const result = await mountNamespaceFs({
|
|
380
|
+
mode: mount.mode,
|
|
381
|
+
vfs: mount.vfs,
|
|
382
|
+
nsRoot: mount.nsRoot,
|
|
383
|
+
addon: mount.addon,
|
|
384
|
+
});
|
|
385
|
+
if (result.mounted) {
|
|
386
|
+
log("fusefs", "reconnected — live views restored");
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
logWarn("fusefs", `health check failed: ${message(err)}`);
|
|
390
|
+
} finally {
|
|
391
|
+
healing = false;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A daemon that died without unmounting leaves the mountpoint wedged —
|
|
397
|
+
* every syscall answers ENOTCONN ("transport endpoint is not
|
|
398
|
+
* connected") until someone detaches it. A predecessor that is alive
|
|
399
|
+
* but not answering is worse: its mount HANGS syscalls instead, so the
|
|
400
|
+
* probe itself carries a timeout and a hang counts as stale. Detect
|
|
401
|
+
* and lazy-unmount before touching the directory.
|
|
402
|
+
*/
|
|
403
|
+
async function recoverStaleMount(nsRoot: string): Promise<void> {
|
|
404
|
+
const probe = await withTimeout(
|
|
405
|
+
stat(nsRoot).then(
|
|
406
|
+
() => "ok" as const,
|
|
407
|
+
(err) => (err as NodeJS.ErrnoException).code ?? "error",
|
|
408
|
+
),
|
|
409
|
+
SANITY_TIMEOUT_MS,
|
|
410
|
+
);
|
|
411
|
+
if (probe !== "ENOTCONN" && probe !== "timeout") return;
|
|
412
|
+
logWarn("fusefs", `stale mount at ${nsRoot} — detaching`);
|
|
413
|
+
for (const bin of ["fusermount3", "fusermount"]) {
|
|
414
|
+
const result = spawnSync(bin, ["-uz", nsRoot], { stdio: "ignore" });
|
|
415
|
+
if (result.status === 0) return;
|
|
416
|
+
}
|
|
417
|
+
spawnSync("umount", ["-l", nsRoot], { stdio: "ignore" });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function down(reason: string): FuseStatus {
|
|
421
|
+
status = { mounted: false, reason };
|
|
422
|
+
log("fusefs", `FUSE layer off — ${reason}`);
|
|
423
|
+
return status;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function message(err: unknown): string {
|
|
427
|
+
return err instanceof Error ? err.message : String(err);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T | "timeout"> {
|
|
431
|
+
return Promise.race([
|
|
432
|
+
work,
|
|
433
|
+
new Promise<"timeout">((resolve) => {
|
|
434
|
+
const timer = setTimeout(() => resolve("timeout"), ms);
|
|
435
|
+
timer.unref();
|
|
436
|
+
}),
|
|
437
|
+
]);
|
|
438
|
+
}
|
package/src/core/vfs/index.ts
CHANGED
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* proc/ task table + event bus ring (synthetic, read-only)
|
|
16
16
|
* plugins/ plugin registry view (synthetic, read-only)
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
18
|
+
* The namespace IS the filesystem: it lives at ~/.talon/ns (symlink
|
|
19
|
+
* farm via nsdir.ts, live synthetic mounts via fusefs.ts when FUSE is
|
|
20
|
+
* available), and every consumer — native tools, shell commands, the
|
|
21
|
+
* user's own terminal — reaches it through real paths. Address
|
|
22
|
+
* translation for tools lives in rewrite.ts.
|
|
20
23
|
*/
|
|
21
24
|
|
|
22
25
|
import { dirs } from "../../util/paths.js";
|
|
@@ -32,6 +35,22 @@ export { Vfs } from "./vfs.js";
|
|
|
32
35
|
export { createFileMount } from "./mounts/files.js";
|
|
33
36
|
export { createProcMount, type ProcMountDeps } from "./mounts/proc.js";
|
|
34
37
|
export { createPluginsMount, type PluginView } from "./mounts/plugins.js";
|
|
38
|
+
export { syncNamespaceDir, type NsDirSync } from "./nsdir.js";
|
|
39
|
+
export {
|
|
40
|
+
resolveNamespacePath,
|
|
41
|
+
rewriteNamespaceRefs,
|
|
42
|
+
type CommandRewrite,
|
|
43
|
+
type PathResolution,
|
|
44
|
+
} from "./rewrite.js";
|
|
45
|
+
export {
|
|
46
|
+
isNamespaceFsMounted,
|
|
47
|
+
mountNamespaceFs,
|
|
48
|
+
namespaceFsStatus,
|
|
49
|
+
serveNamespaceRequest,
|
|
50
|
+
unmountNamespaceFs,
|
|
51
|
+
type FuseStatus,
|
|
52
|
+
type MountOptions,
|
|
53
|
+
} from "./fusefs.js";
|
|
35
54
|
export type {
|
|
36
55
|
VfsErrorCode,
|
|
37
56
|
VfsMount,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The namespace on disk — ~/.talon/ns/, the OS spelling of talon://.
|
|
3
|
+
*
|
|
4
|
+
* One symlink per file-backed mount (home → workspace/, skills/, …), so
|
|
5
|
+
* `talon://` ↔ `~/.talon/ns/` is a pure prefix substitution: any shell
|
|
6
|
+
* command, editor, or external tool reaches namespace nodes through
|
|
7
|
+
* ordinary paths. Synthetic mounts (proc/, plugins/) are NOT represented
|
|
8
|
+
* here — they exist only while the FUSE layer (core/vfs/fusefs.ts) is
|
|
9
|
+
* mounted over this directory, exactly like /proc appearing at boot.
|
|
10
|
+
*
|
|
11
|
+
* The sync is idempotent and owns only symlinks: stale or retargeted
|
|
12
|
+
* links it created are replaced, anything that isn't a symlink is left
|
|
13
|
+
* untouched and reported rather than deleted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readlinkSync,
|
|
21
|
+
rmdirSync,
|
|
22
|
+
symlinkSync,
|
|
23
|
+
unlinkSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { dirs } from "../../util/paths.js";
|
|
26
|
+
import type { Vfs } from "./vfs.js";
|
|
27
|
+
|
|
28
|
+
export interface NsDirSync {
|
|
29
|
+
/** Symlinks created or retargeted this pass (mount names). */
|
|
30
|
+
readonly linked: string[];
|
|
31
|
+
/** Stale symlinks removed (no longer a mount, or wrong target). */
|
|
32
|
+
readonly pruned: string[];
|
|
33
|
+
/** Entries skipped because they aren't symlinks we own. */
|
|
34
|
+
readonly foreign: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Bring ~/.talon/ns/ (or `nsRoot`) in line with the mount table. Safe to
|
|
39
|
+
* call repeatedly; called at daemon boot before the FUSE layer mounts
|
|
40
|
+
* over the directory. Mount targets are created if missing so the links
|
|
41
|
+
* are never dangling — workspace directories appear lazily elsewhere too.
|
|
42
|
+
*/
|
|
43
|
+
export function syncNamespaceDir(
|
|
44
|
+
vfs: Vfs,
|
|
45
|
+
nsRoot: string = dirs.ns,
|
|
46
|
+
): NsDirSync {
|
|
47
|
+
mkdirSync(nsRoot, { recursive: true });
|
|
48
|
+
const desired = new Map(
|
|
49
|
+
vfs
|
|
50
|
+
.describeMounts()
|
|
51
|
+
.filter((mount) => mount.osRoot !== undefined)
|
|
52
|
+
.map((mount) => [mount.name, mount.osRoot!]),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const linked: string[] = [];
|
|
56
|
+
const pruned: string[] = [];
|
|
57
|
+
const foreign: string[] = [];
|
|
58
|
+
|
|
59
|
+
for (const entry of readdirSync(nsRoot)) {
|
|
60
|
+
const path = `${nsRoot}/${entry}`;
|
|
61
|
+
if (!lstatSync(path).isSymbolicLink()) {
|
|
62
|
+
foreign.push(entry);
|
|
63
|
+
// A foreign entry shadowing a mount name wins — we never delete
|
|
64
|
+
// what we didn't create, so don't try to link over it either.
|
|
65
|
+
desired.delete(entry);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const target = desired.get(entry);
|
|
69
|
+
if (target !== undefined && readlinkSync(path) === target) {
|
|
70
|
+
desired.delete(entry); // already correct
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Remove the LINK, never its target. unlink is the precise
|
|
74
|
+
// primitive for that everywhere except Windows, where a
|
|
75
|
+
// directory-symlink is removed with rmdir instead — hence the
|
|
76
|
+
// fallback. (rmSync's contract on symlinks is murkier; these two
|
|
77
|
+
// never touch the target by definition.)
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(path);
|
|
80
|
+
} catch {
|
|
81
|
+
rmdirSync(path);
|
|
82
|
+
}
|
|
83
|
+
if (target === undefined) pruned.push(entry);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [name, target] of desired) {
|
|
87
|
+
mkdirSync(target, { recursive: true });
|
|
88
|
+
// "dir" matters only on Windows (junction-style resolution); ignored
|
|
89
|
+
// elsewhere.
|
|
90
|
+
symlinkSync(target, `${nsRoot}/${name}`, "dir");
|
|
91
|
+
linked.push(name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { linked, pruned, foreign };
|
|
95
|
+
}
|