@dench.com/cli 0.3.1 → 0.3.3
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 +57 -0
- package/agent.ts +391 -0
- package/chat.ts +216 -0
- package/crm.ts +16 -5
- package/dench.ts +639 -29
- package/fs-daemon.ts +802 -28
- package/package.json +3 -1
package/fs-daemon.ts
CHANGED
|
@@ -18,7 +18,22 @@
|
|
|
18
18
|
* • Optimistic conflict via `expectedPreviousHash`. On 409, we fetch
|
|
19
19
|
* latest, write to `<path>.conflicted-<ts>`, surface in UI.
|
|
20
20
|
* • Per-path circuit breaker: if a path syncs >5 times in 10s, pause
|
|
21
|
-
* sync on that path and
|
|
21
|
+
* sync on that path and log loudly to stderr (the live state of
|
|
22
|
+
* active breakers is exposed via `dench fs status`).
|
|
23
|
+
*
|
|
24
|
+
* FUSE / Mountpoint-for-S3 caveats:
|
|
25
|
+
* /workspace is a Daytona persistent volume, mounted via AWS
|
|
26
|
+
* Mountpoint for S3 (a FUSE driver). Two upstream limitations
|
|
27
|
+
* matter:
|
|
28
|
+
* • inotify events never fire on the mount
|
|
29
|
+
* (awslabs/mountpoint-s3#1290). We auto-detect FUSE backing via
|
|
30
|
+
* `/proc/self/mountinfo` and switch chokidar to `usePolling`
|
|
31
|
+
* mode in that case.
|
|
32
|
+
* • rename(2) returns ENOSYS on general-purpose buckets, so the
|
|
33
|
+
* daemon never relies on rename to detect moves; we just see
|
|
34
|
+
* add/unlink pairs through chokidar's polling backend.
|
|
35
|
+
* A periodic reconciliation walk also runs on a 5s tick (10s on
|
|
36
|
+
* non-FUSE) to catch anything chokidar missed.
|
|
22
37
|
*
|
|
23
38
|
* Live streaming subcommands (one-shot, not persistent):
|
|
24
39
|
* dench-fs-daemon stream-open <path> -> prints token
|
|
@@ -35,10 +50,23 @@
|
|
|
35
50
|
*/
|
|
36
51
|
import { createHash } from "node:crypto";
|
|
37
52
|
import { mkdirSync } from "node:fs";
|
|
38
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
mkdir,
|
|
55
|
+
readdir,
|
|
56
|
+
readFile,
|
|
57
|
+
stat,
|
|
58
|
+
writeFile,
|
|
59
|
+
} from "node:fs/promises";
|
|
39
60
|
import { dirname, relative } from "node:path";
|
|
40
61
|
import { ConvexClient, ConvexHttpClient } from "convex/browser";
|
|
41
62
|
import { makeFunctionReference } from "convex/server";
|
|
63
|
+
import {
|
|
64
|
+
detectWorkspaceFsType,
|
|
65
|
+
findMountForPath,
|
|
66
|
+
isFuseFsType,
|
|
67
|
+
type MountInfo,
|
|
68
|
+
readMountInfo,
|
|
69
|
+
} from "./fs-daemon-mount";
|
|
42
70
|
|
|
43
71
|
type Args = {
|
|
44
72
|
orgId?: string;
|
|
@@ -79,6 +107,7 @@ function parseArgs(argv: string[]): Args {
|
|
|
79
107
|
function shouldIgnore(relPath: string): boolean {
|
|
80
108
|
const parts = relPath.split("/").filter(Boolean);
|
|
81
109
|
const blockedDirs = new Set([
|
|
110
|
+
".chrome-profile",
|
|
82
111
|
".git",
|
|
83
112
|
".next",
|
|
84
113
|
".vercel",
|
|
@@ -110,12 +139,29 @@ const SYNC_DEBOUNCE_MS = 300;
|
|
|
110
139
|
const MAX_INLINE_TEXT_BYTES = 100_000;
|
|
111
140
|
const CIRCUIT_BREAKER_LIMIT = 5;
|
|
112
141
|
const CIRCUIT_BREAKER_WINDOW_MS = 10_000;
|
|
142
|
+
const CIRCUIT_BREAKER_PAUSE_MS = 60_000;
|
|
143
|
+
// Reconciliation interval. AWS documents Mountpoint-for-S3's metadata
|
|
144
|
+
// staleness window at ~1s; polling tighter than 2s wastes round-trips
|
|
145
|
+
// without catching anything new.
|
|
146
|
+
const RECONCILE_INTERVAL_FUSE_MS = 5_000;
|
|
147
|
+
const RECONCILE_INTERVAL_DEFAULT_MS = 30_000;
|
|
148
|
+
// Status / breaker state visible to `dench fs status` over a tiny
|
|
149
|
+
// JSON file the daemon refreshes after each tick.
|
|
150
|
+
const DAEMON_STATUS_PATH = "/tmp/dench-fs-daemon.status.json";
|
|
151
|
+
|
|
152
|
+
type BreakerState = {
|
|
153
|
+
path: string;
|
|
154
|
+
trippedAt: number;
|
|
155
|
+
resumesAt: number;
|
|
156
|
+
syncsInWindow: number;
|
|
157
|
+
};
|
|
113
158
|
|
|
114
159
|
class HashTracker {
|
|
115
160
|
private hashes = new Map<string, string>();
|
|
116
161
|
private timers = new Map<string, NodeJS.Timeout>();
|
|
117
162
|
private recentSyncs = new Map<string, number[]>();
|
|
118
|
-
private paused = new
|
|
163
|
+
private paused = new Map<string, BreakerState>();
|
|
164
|
+
private onBreakerTrip?: (state: BreakerState) => void;
|
|
119
165
|
|
|
120
166
|
get(absPath: string): string | undefined {
|
|
121
167
|
return this.hashes.get(absPath);
|
|
@@ -125,6 +171,29 @@ class HashTracker {
|
|
|
125
171
|
this.hashes.set(absPath, hash);
|
|
126
172
|
}
|
|
127
173
|
|
|
174
|
+
forget(absPath: string): void {
|
|
175
|
+
this.hashes.delete(absPath);
|
|
176
|
+
const timer = this.timers.get(absPath);
|
|
177
|
+
if (timer) {
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
this.timers.delete(absPath);
|
|
180
|
+
}
|
|
181
|
+
this.recentSyncs.delete(absPath);
|
|
182
|
+
this.paused.delete(absPath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
knownPaths(): string[] {
|
|
186
|
+
return [...this.hashes.keys()];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
activeBreakers(): BreakerState[] {
|
|
190
|
+
return [...this.paused.values()];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setBreakerListener(listener: (state: BreakerState) => void): void {
|
|
194
|
+
this.onBreakerTrip = listener;
|
|
195
|
+
}
|
|
196
|
+
|
|
128
197
|
/**
|
|
129
198
|
* Returns true if the path is currently throttled by the circuit
|
|
130
199
|
* breaker (>5 syncs in the last 10s).
|
|
@@ -138,17 +207,26 @@ class HashTracker {
|
|
|
138
207
|
recent.push(now);
|
|
139
208
|
this.recentSyncs.set(absPath, recent);
|
|
140
209
|
if (recent.length > CIRCUIT_BREAKER_LIMIT) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
210
|
+
const state: BreakerState = {
|
|
211
|
+
path: absPath,
|
|
212
|
+
trippedAt: now,
|
|
213
|
+
resumesAt: now + CIRCUIT_BREAKER_PAUSE_MS,
|
|
214
|
+
syncsInWindow: recent.length,
|
|
215
|
+
};
|
|
216
|
+
this.paused.set(absPath, state);
|
|
217
|
+
// Loud enough to land in the supervisor's tail of fs-daemon.log
|
|
218
|
+
// (sandbox-bootstrap.ts surfaces the last 50 lines on failure).
|
|
219
|
+
console.error(
|
|
220
|
+
`[dench-fs-daemon] CIRCUIT_BREAKER tripped path=${absPath} syncs=${recent.length} pause=${CIRCUIT_BREAKER_PAUSE_MS}ms`,
|
|
144
221
|
);
|
|
222
|
+
this.onBreakerTrip?.(state);
|
|
145
223
|
setTimeout(() => {
|
|
146
224
|
this.paused.delete(absPath);
|
|
147
225
|
this.recentSyncs.delete(absPath);
|
|
148
226
|
console.warn(
|
|
149
227
|
`[dench-fs-daemon] circuit breaker released for ${absPath}`,
|
|
150
228
|
);
|
|
151
|
-
},
|
|
229
|
+
}, CIRCUIT_BREAKER_PAUSE_MS);
|
|
152
230
|
return true;
|
|
153
231
|
}
|
|
154
232
|
return false;
|
|
@@ -171,6 +249,18 @@ class HashTracker {
|
|
|
171
249
|
}
|
|
172
250
|
}
|
|
173
251
|
|
|
252
|
+
// Mount detection helpers live in fs-daemon-mount.ts so they can be
|
|
253
|
+
// unit-tested without pulling chokidar / convex / etc. They are
|
|
254
|
+
// re-exported here so existing imports of `cli/fs-daemon` keep
|
|
255
|
+
// working in case anything outside this file relied on them.
|
|
256
|
+
export {
|
|
257
|
+
detectWorkspaceFsType,
|
|
258
|
+
findMountForPath,
|
|
259
|
+
isFuseFsType,
|
|
260
|
+
readMountInfo,
|
|
261
|
+
type MountInfo,
|
|
262
|
+
};
|
|
263
|
+
|
|
174
264
|
// Convex function references — must match `convex/_generated/api.d.ts` paths.
|
|
175
265
|
const api = {
|
|
176
266
|
files: {
|
|
@@ -276,6 +366,21 @@ class DenchFileClient {
|
|
|
276
366
|
});
|
|
277
367
|
}
|
|
278
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Read direct children of a Convex file-tree prefix. Used by `dench
|
|
371
|
+
* fs status` to recursively crawl the canonical tree and diff
|
|
372
|
+
* against the local FS snapshot.
|
|
373
|
+
*/
|
|
374
|
+
async listTree(prefix: string): Promise<
|
|
375
|
+
Array<{ path: string; contentHash?: string; isDir: boolean }>
|
|
376
|
+
> {
|
|
377
|
+
const rows = (await this.http.query(api.files.listTree, {
|
|
378
|
+
prefix,
|
|
379
|
+
apiKey: this.apiKey,
|
|
380
|
+
})) as Array<{ path: string; contentHash?: string; isDir: boolean }>;
|
|
381
|
+
return Array.isArray(rows) ? rows : [];
|
|
382
|
+
}
|
|
383
|
+
|
|
279
384
|
async getSignedDownloadUrl(path: string): Promise<string | null> {
|
|
280
385
|
const url = (await this.http.action(api.files.getFileSignedDownloadUrl, {
|
|
281
386
|
path,
|
|
@@ -359,45 +464,80 @@ async function pullDownRemoteFile(
|
|
|
359
464
|
);
|
|
360
465
|
}
|
|
361
466
|
|
|
467
|
+
/**
|
|
468
|
+
* sysexits.h-style exit codes the supervisor (in
|
|
469
|
+
* src/lib/daytona/sandbox-bootstrap.ts) recognizes as **permanent** —
|
|
470
|
+
* keep restarting won't fix a missing env var or a missing dependency,
|
|
471
|
+
* so the supervisor gives up instead of crash-looping. Anything else
|
|
472
|
+
* (network blip, transient Convex error) exits 1 and gets retried.
|
|
473
|
+
*/
|
|
474
|
+
const EXIT_CONFIG_ERROR = 78;
|
|
475
|
+
|
|
362
476
|
async function runDaemon(daemonArgs: Args): Promise<void> {
|
|
363
477
|
const convexUrl = process.env.CONVEX_URL?.trim();
|
|
364
478
|
const apiKey = process.env.DENCH_API_KEY?.trim();
|
|
365
479
|
if (!convexUrl || !apiKey) {
|
|
366
480
|
console.error(
|
|
367
|
-
"[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env;
|
|
481
|
+
"[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env; refusing to start",
|
|
368
482
|
);
|
|
369
|
-
|
|
370
|
-
return;
|
|
483
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
371
484
|
}
|
|
372
485
|
|
|
373
486
|
const client = new DenchFileClient(convexUrl, apiKey);
|
|
374
487
|
const resolvedOrgId =
|
|
375
488
|
daemonArgs.orgId ?? (await client.resolveOrganizationId());
|
|
489
|
+
const mount = await detectWorkspaceFsType(daemonArgs.workspace);
|
|
376
490
|
console.log(
|
|
377
|
-
`[dench-fs-daemon] starting org=${resolvedOrgId} workspace=${daemonArgs.workspace}`,
|
|
491
|
+
`[dench-fs-daemon] starting org=${resolvedOrgId} workspace=${daemonArgs.workspace} fsType=${mount.fsType}${mount.isFuse ? " (FUSE)" : ""}`,
|
|
378
492
|
);
|
|
493
|
+
if (mount.isFuse) {
|
|
494
|
+
console.log(
|
|
495
|
+
"[dench-fs-daemon] FUSE backing detected; enabling polling mode (inotify is not delivered through Mountpoint-S3)",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
379
498
|
mkdirSync(daemonArgs.workspace, { recursive: true });
|
|
380
499
|
mkdirSync(daemonArgs.hotCache, { recursive: true });
|
|
381
500
|
|
|
382
501
|
const tracker = new HashTracker();
|
|
502
|
+
const status = new DaemonStatusWriter({
|
|
503
|
+
workspace: daemonArgs.workspace,
|
|
504
|
+
organizationId: resolvedOrgId,
|
|
505
|
+
fsType: mount.fsType,
|
|
506
|
+
isFuse: mount.isFuse,
|
|
507
|
+
});
|
|
508
|
+
tracker.setBreakerListener((breakerState) => {
|
|
509
|
+
status.recordBreakerEvent(breakerState);
|
|
510
|
+
void status.flush();
|
|
511
|
+
});
|
|
512
|
+
await status.flush();
|
|
383
513
|
|
|
384
514
|
let chokidar: typeof import("chokidar");
|
|
385
515
|
try {
|
|
386
516
|
chokidar = (await import("chokidar")) as never;
|
|
387
517
|
} catch (error) {
|
|
388
518
|
console.error(
|
|
389
|
-
`[dench-fs-daemon] chokidar not available;
|
|
519
|
+
`[dench-fs-daemon] chokidar not available; refusing to start (${
|
|
390
520
|
error instanceof Error ? error.message : String(error)
|
|
391
521
|
})`,
|
|
392
522
|
);
|
|
393
|
-
|
|
394
|
-
return;
|
|
523
|
+
process.exit(EXIT_CONFIG_ERROR);
|
|
395
524
|
}
|
|
396
525
|
|
|
526
|
+
// On FUSE mounts inotify never fires, so we force chokidar's polling
|
|
527
|
+
// backend (poll every 2.5s for files / 5s for binary). On non-FUSE
|
|
528
|
+
// mounts the inotify backend is fine and saves CPU.
|
|
529
|
+
const reconcileIntervalMs = mount.isFuse
|
|
530
|
+
? RECONCILE_INTERVAL_FUSE_MS
|
|
531
|
+
: RECONCILE_INTERVAL_DEFAULT_MS;
|
|
397
532
|
const watcher = chokidar.watch(daemonArgs.workspace, {
|
|
398
533
|
ignoreInitial: true,
|
|
399
534
|
persistent: true,
|
|
400
|
-
|
|
535
|
+
usePolling: mount.isFuse,
|
|
536
|
+
interval: mount.isFuse ? 2_500 : undefined,
|
|
537
|
+
binaryInterval: mount.isFuse ? 5_000 : undefined,
|
|
538
|
+
awaitWriteFinish: mount.isFuse
|
|
539
|
+
? false
|
|
540
|
+
: { stabilityThreshold: 100, pollInterval: 50 },
|
|
401
541
|
ignored: (path: string) => {
|
|
402
542
|
const rel = relative(daemonArgs.workspace, path);
|
|
403
543
|
if (rel.startsWith("..")) return true;
|
|
@@ -412,6 +552,7 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
|
|
|
412
552
|
if (shouldIgnore(rel)) return;
|
|
413
553
|
try {
|
|
414
554
|
await client.deleteFile(`/${rel}`);
|
|
555
|
+
tracker.forget(absPath);
|
|
415
556
|
console.log(`[dench-fs-daemon] deleted /${rel}`);
|
|
416
557
|
} catch (error) {
|
|
417
558
|
console.error(
|
|
@@ -500,16 +641,271 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
|
|
|
500
641
|
}
|
|
501
642
|
});
|
|
502
643
|
|
|
644
|
+
// Periodic walk + reconciliation. Catches changes chokidar missed
|
|
645
|
+
// (any file write on a Mountpoint volume from a process other than
|
|
646
|
+
// this sandbox, or from the Daytona SDK uploadFile path) and writes
|
|
647
|
+
// a heartbeat to the status file so `dench fs status` can confirm
|
|
648
|
+
// the daemon is alive and what it last did.
|
|
649
|
+
const stopReconcile = startReconcileLoop({
|
|
650
|
+
workspace: daemonArgs.workspace,
|
|
651
|
+
intervalMs: reconcileIntervalMs,
|
|
652
|
+
tracker,
|
|
653
|
+
status,
|
|
654
|
+
onLocalChange: scheduleSync,
|
|
655
|
+
onLocalDelete: async (absPath) => {
|
|
656
|
+
const rel = relative(daemonArgs.workspace, absPath);
|
|
657
|
+
if (shouldIgnore(rel)) return;
|
|
658
|
+
try {
|
|
659
|
+
await client.deleteFile(`/${rel}`);
|
|
660
|
+
tracker.forget(absPath);
|
|
661
|
+
console.log(
|
|
662
|
+
`[dench-fs-daemon] reconcile deleted /${rel} (chokidar missed unlink)`,
|
|
663
|
+
);
|
|
664
|
+
} catch (error) {
|
|
665
|
+
console.error(
|
|
666
|
+
`[dench-fs-daemon] reconcile delete failed for /${rel}: ${
|
|
667
|
+
error instanceof Error ? error.message : String(error)
|
|
668
|
+
}`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
|
|
503
674
|
process.on("SIGTERM", () => {
|
|
675
|
+
stopReconcile();
|
|
504
676
|
unsubscribe();
|
|
505
677
|
watcher.close().catch(() => undefined);
|
|
506
678
|
process.exit(0);
|
|
507
679
|
});
|
|
508
680
|
|
|
509
|
-
console.log(
|
|
681
|
+
console.log(
|
|
682
|
+
`[dench-fs-daemon] watching ${daemonArgs.workspace} (reconcile every ${reconcileIntervalMs}ms)`,
|
|
683
|
+
);
|
|
510
684
|
setInterval(() => undefined, 60_000);
|
|
511
685
|
}
|
|
512
686
|
|
|
687
|
+
// ── Reconciliation loop ────────────────────────────────────────────────────
|
|
688
|
+
//
|
|
689
|
+
// Runs on a fixed tick and:
|
|
690
|
+
// • Walks the workspace, collects (absPath, mtimeMs, size) for every
|
|
691
|
+
// non-ignored file.
|
|
692
|
+
// • Compares against the in-memory tracker (`tracker.knownPaths()`).
|
|
693
|
+
// New paths or paths whose mtime/size moved → schedule a sync.
|
|
694
|
+
// • Paths the tracker knew about that are now absent from disk →
|
|
695
|
+
// enqueue a delete.
|
|
696
|
+
// • Refreshes the daemon status file with the latest scan summary.
|
|
697
|
+
|
|
698
|
+
type FileSnapshot = {
|
|
699
|
+
absPath: string;
|
|
700
|
+
size: number;
|
|
701
|
+
mtimeMs: number;
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
async function snapshotWorkspace(
|
|
705
|
+
workspace: string,
|
|
706
|
+
): Promise<Map<string, FileSnapshot>> {
|
|
707
|
+
const result = new Map<string, FileSnapshot>();
|
|
708
|
+
const queue: string[] = [workspace];
|
|
709
|
+
while (queue.length > 0) {
|
|
710
|
+
const dir = queue.shift()!;
|
|
711
|
+
type DirEntryLike = {
|
|
712
|
+
name: string;
|
|
713
|
+
isDirectory(): boolean;
|
|
714
|
+
isFile(): boolean;
|
|
715
|
+
};
|
|
716
|
+
let entries: DirEntryLike[];
|
|
717
|
+
try {
|
|
718
|
+
entries = (await readdir(dir, {
|
|
719
|
+
withFileTypes: true,
|
|
720
|
+
})) as unknown as DirEntryLike[];
|
|
721
|
+
} catch {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
const abs = `${dir.replace(/\/+$/, "")}/${entry.name}`;
|
|
726
|
+
const rel = relative(workspace, abs);
|
|
727
|
+
if (rel.startsWith("..") || shouldIgnore(rel)) continue;
|
|
728
|
+
if (entry.isDirectory()) {
|
|
729
|
+
queue.push(abs);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (!entry.isFile()) continue;
|
|
733
|
+
const stats = await stat(abs).catch(() => null);
|
|
734
|
+
if (!stats) continue;
|
|
735
|
+
result.set(abs, {
|
|
736
|
+
absPath: abs,
|
|
737
|
+
size: stats.size,
|
|
738
|
+
mtimeMs: stats.mtimeMs,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return result;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function startReconcileLoop(args: {
|
|
746
|
+
workspace: string;
|
|
747
|
+
intervalMs: number;
|
|
748
|
+
tracker: HashTracker;
|
|
749
|
+
status: DaemonStatusWriter;
|
|
750
|
+
onLocalChange: (absPath: string) => void;
|
|
751
|
+
onLocalDelete: (absPath: string) => Promise<void> | void;
|
|
752
|
+
}): () => void {
|
|
753
|
+
const last = new Map<string, FileSnapshot>();
|
|
754
|
+
|
|
755
|
+
let stopped = false;
|
|
756
|
+
const tick = async () => {
|
|
757
|
+
if (stopped) return;
|
|
758
|
+
const start = Date.now();
|
|
759
|
+
let observed = 0;
|
|
760
|
+
let scheduled = 0;
|
|
761
|
+
let deleted = 0;
|
|
762
|
+
try {
|
|
763
|
+
const snapshot = await snapshotWorkspace(args.workspace);
|
|
764
|
+
observed = snapshot.size;
|
|
765
|
+
for (const [absPath, info] of snapshot) {
|
|
766
|
+
const prev = last.get(absPath);
|
|
767
|
+
if (!prev || prev.size !== info.size || prev.mtimeMs !== info.mtimeMs) {
|
|
768
|
+
args.onLocalChange(absPath);
|
|
769
|
+
scheduled++;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
for (const absPath of last.keys()) {
|
|
773
|
+
if (!snapshot.has(absPath)) {
|
|
774
|
+
await args.onLocalDelete(absPath);
|
|
775
|
+
deleted++;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
last.clear();
|
|
779
|
+
for (const [absPath, info] of snapshot) {
|
|
780
|
+
last.set(absPath, info);
|
|
781
|
+
}
|
|
782
|
+
} catch (error) {
|
|
783
|
+
console.error(
|
|
784
|
+
`[dench-fs-daemon] reconcile scan failed: ${
|
|
785
|
+
error instanceof Error ? error.message : String(error)
|
|
786
|
+
}`,
|
|
787
|
+
);
|
|
788
|
+
} finally {
|
|
789
|
+
args.status.recordReconcile({
|
|
790
|
+
finishedAt: Date.now(),
|
|
791
|
+
durationMs: Date.now() - start,
|
|
792
|
+
observed,
|
|
793
|
+
scheduledForSync: scheduled,
|
|
794
|
+
deleted,
|
|
795
|
+
breakers: args.tracker.activeBreakers(),
|
|
796
|
+
});
|
|
797
|
+
await args.status.flush();
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
// Stagger the first tick so we don't race chokidar's initial add
|
|
802
|
+
// floods on daemon startup.
|
|
803
|
+
const initial = setTimeout(() => {
|
|
804
|
+
void tick();
|
|
805
|
+
}, Math.min(args.intervalMs, 5_000));
|
|
806
|
+
const interval = setInterval(() => {
|
|
807
|
+
void tick();
|
|
808
|
+
}, args.intervalMs);
|
|
809
|
+
|
|
810
|
+
return () => {
|
|
811
|
+
stopped = true;
|
|
812
|
+
clearTimeout(initial);
|
|
813
|
+
clearInterval(interval);
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ── Status file ────────────────────────────────────────────────────────────
|
|
818
|
+
//
|
|
819
|
+
// `dench fs status` reads /tmp/dench-fs-daemon.status.json so the agent
|
|
820
|
+
// (and humans) can quickly see whether the daemon is alive, what
|
|
821
|
+
// filesystem it thinks /workspace is on, and how many files it just
|
|
822
|
+
// scanned. Lives on tmpfs so it never lands in the volume itself.
|
|
823
|
+
|
|
824
|
+
type DaemonStatus = {
|
|
825
|
+
pid: number;
|
|
826
|
+
startedAt: number;
|
|
827
|
+
lastUpdatedAt: number;
|
|
828
|
+
workspace: string;
|
|
829
|
+
organizationId: string;
|
|
830
|
+
fsType: string;
|
|
831
|
+
isFuse: boolean;
|
|
832
|
+
lastReconcile: {
|
|
833
|
+
finishedAt: number;
|
|
834
|
+
durationMs: number;
|
|
835
|
+
observed: number;
|
|
836
|
+
scheduledForSync: number;
|
|
837
|
+
deleted: number;
|
|
838
|
+
} | null;
|
|
839
|
+
activeBreakers: BreakerState[];
|
|
840
|
+
recentBreakerEvents: BreakerState[];
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
class DaemonStatusWriter {
|
|
844
|
+
private state: DaemonStatus;
|
|
845
|
+
private writing: Promise<void> | null = null;
|
|
846
|
+
|
|
847
|
+
constructor(args: {
|
|
848
|
+
workspace: string;
|
|
849
|
+
organizationId: string;
|
|
850
|
+
fsType: string;
|
|
851
|
+
isFuse: boolean;
|
|
852
|
+
}) {
|
|
853
|
+
const now = Date.now();
|
|
854
|
+
this.state = {
|
|
855
|
+
pid: process.pid,
|
|
856
|
+
startedAt: now,
|
|
857
|
+
lastUpdatedAt: now,
|
|
858
|
+
workspace: args.workspace,
|
|
859
|
+
organizationId: args.organizationId,
|
|
860
|
+
fsType: args.fsType,
|
|
861
|
+
isFuse: args.isFuse,
|
|
862
|
+
lastReconcile: null,
|
|
863
|
+
activeBreakers: [],
|
|
864
|
+
recentBreakerEvents: [],
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
recordReconcile(args: {
|
|
869
|
+
finishedAt: number;
|
|
870
|
+
durationMs: number;
|
|
871
|
+
observed: number;
|
|
872
|
+
scheduledForSync: number;
|
|
873
|
+
deleted: number;
|
|
874
|
+
breakers: BreakerState[];
|
|
875
|
+
}): void {
|
|
876
|
+
this.state.lastReconcile = {
|
|
877
|
+
finishedAt: args.finishedAt,
|
|
878
|
+
durationMs: args.durationMs,
|
|
879
|
+
observed: args.observed,
|
|
880
|
+
scheduledForSync: args.scheduledForSync,
|
|
881
|
+
deleted: args.deleted,
|
|
882
|
+
};
|
|
883
|
+
this.state.activeBreakers = args.breakers;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
recordBreakerEvent(state: BreakerState): void {
|
|
887
|
+
const recent = [...this.state.recentBreakerEvents, state];
|
|
888
|
+
// Cap at last 10 events so the file stays small.
|
|
889
|
+
while (recent.length > 10) recent.shift();
|
|
890
|
+
this.state.recentBreakerEvents = recent;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async flush(): Promise<void> {
|
|
894
|
+
this.state.lastUpdatedAt = Date.now();
|
|
895
|
+
const body = `${JSON.stringify(this.state, null, 2)}\n`;
|
|
896
|
+
// Serialize so concurrent flushes don't interleave writes.
|
|
897
|
+
const previous = this.writing ?? Promise.resolve();
|
|
898
|
+
this.writing = previous
|
|
899
|
+
.catch(() => undefined)
|
|
900
|
+
.then(() =>
|
|
901
|
+
writeFile(DAEMON_STATUS_PATH, body, { encoding: "utf-8" }).catch(
|
|
902
|
+
() => undefined,
|
|
903
|
+
),
|
|
904
|
+
);
|
|
905
|
+
await this.writing;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
513
909
|
// ── Live streaming subcommands ─────────────────────────────────────────────
|
|
514
910
|
//
|
|
515
911
|
// Tokens are stored in /tmp/dench-stream-tokens.json so subsequent stream-
|
|
@@ -616,10 +1012,366 @@ async function main(): Promise<void> {
|
|
|
616
1012
|
await runInitialSync(argv.slice(1));
|
|
617
1013
|
return;
|
|
618
1014
|
}
|
|
1015
|
+
if (subcommand === "status") {
|
|
1016
|
+
await runStatus(argv.slice(1));
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
619
1019
|
// Daemon mode (long-running, started by sandbox-bootstrap).
|
|
620
1020
|
await runDaemon(parseArgs(argv));
|
|
621
1021
|
}
|
|
622
1022
|
|
|
1023
|
+
// ── Status subcommand ─────────────────────────────────────────────────────
|
|
1024
|
+
//
|
|
1025
|
+
// `dench-fs-daemon status [--json] [--workspace /workspace]` prints a
|
|
1026
|
+
// quick health readout: process up?, last reconcile tick, active
|
|
1027
|
+
// circuit breakers, file count on disk vs Convex tree, and a
|
|
1028
|
+
// per-path drift list. Designed to be called from a sandbox shell
|
|
1029
|
+
// (or `dench fs status`) without any existing CLI context.
|
|
1030
|
+
|
|
1031
|
+
const FS_DAEMON_LOG_PATH = "/var/log/dench/fs-daemon.log";
|
|
1032
|
+
|
|
1033
|
+
type DriftItem =
|
|
1034
|
+
| { kind: "only_disk"; path: string; size: number; mtimeMs: number }
|
|
1035
|
+
| { kind: "only_convex"; path: string }
|
|
1036
|
+
| {
|
|
1037
|
+
kind: "hash_mismatch";
|
|
1038
|
+
path: string;
|
|
1039
|
+
diskSize: number;
|
|
1040
|
+
diskHashShort: string;
|
|
1041
|
+
convexHashShort: string;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
type StatusReport = {
|
|
1045
|
+
daemon: {
|
|
1046
|
+
pidAlive: boolean;
|
|
1047
|
+
statusFilePath: string;
|
|
1048
|
+
statusFileExists: boolean;
|
|
1049
|
+
statusFileAgeMs: number | null;
|
|
1050
|
+
state: DaemonStatus | null;
|
|
1051
|
+
};
|
|
1052
|
+
filesystem: {
|
|
1053
|
+
workspace: string;
|
|
1054
|
+
fsType: string;
|
|
1055
|
+
isFuse: boolean;
|
|
1056
|
+
source: string;
|
|
1057
|
+
};
|
|
1058
|
+
counts: {
|
|
1059
|
+
onDisk: number;
|
|
1060
|
+
inConvex: number;
|
|
1061
|
+
onlyOnDisk: number;
|
|
1062
|
+
onlyInConvex: number;
|
|
1063
|
+
hashMismatches: number;
|
|
1064
|
+
};
|
|
1065
|
+
drift: DriftItem[];
|
|
1066
|
+
recentLogTail: string[];
|
|
1067
|
+
};
|
|
1068
|
+
|
|
1069
|
+
async function runStatus(argv: string[]): Promise<void> {
|
|
1070
|
+
const json = argv.includes("--json");
|
|
1071
|
+
const workspaceIndex = argv.indexOf("--workspace");
|
|
1072
|
+
const workspace =
|
|
1073
|
+
workspaceIndex === -1
|
|
1074
|
+
? (process.env.DENCH_VOLUME_PATH?.trim() || "/workspace")
|
|
1075
|
+
: (argv[workspaceIndex + 1] ?? "/workspace");
|
|
1076
|
+
const driftLimitIndex = argv.indexOf("--drift-limit");
|
|
1077
|
+
const driftLimit =
|
|
1078
|
+
driftLimitIndex === -1
|
|
1079
|
+
? 50
|
|
1080
|
+
: Math.max(0, Number.parseInt(argv[driftLimitIndex + 1] ?? "50", 10));
|
|
1081
|
+
const skipHash = argv.includes("--no-hash");
|
|
1082
|
+
|
|
1083
|
+
const report = await collectStatus({ workspace, driftLimit, hash: !skipHash });
|
|
1084
|
+
if (json) {
|
|
1085
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
printStatusHuman(report);
|
|
1089
|
+
// Exit non-zero when the daemon is missing or has obvious drift —
|
|
1090
|
+
// makes it usable in `if dench-fs-daemon status; then …` checks.
|
|
1091
|
+
if (!report.daemon.pidAlive || report.counts.hashMismatches > 0) {
|
|
1092
|
+
process.exitCode = 1;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
async function collectStatus(args: {
|
|
1097
|
+
workspace: string;
|
|
1098
|
+
driftLimit: number;
|
|
1099
|
+
hash: boolean;
|
|
1100
|
+
}): Promise<StatusReport> {
|
|
1101
|
+
const [statusFile, mount, logTail] = await Promise.all([
|
|
1102
|
+
readStatusFile(),
|
|
1103
|
+
detectWorkspaceFsType(args.workspace),
|
|
1104
|
+
readLogTail(20),
|
|
1105
|
+
]);
|
|
1106
|
+
|
|
1107
|
+
const pidAlive = statusFile.state ? isPidAlive(statusFile.state.pid) : false;
|
|
1108
|
+
|
|
1109
|
+
let onDiskList: Array<{ path: string; size: number; mtimeMs: number }> = [];
|
|
1110
|
+
let convexRows: Array<{ path: string; contentHash?: string; isDir: boolean }> =
|
|
1111
|
+
[];
|
|
1112
|
+
try {
|
|
1113
|
+
const snapshot = await snapshotWorkspace(args.workspace);
|
|
1114
|
+
for (const [absPath, info] of snapshot) {
|
|
1115
|
+
const rel = relative(args.workspace, absPath);
|
|
1116
|
+
if (rel.startsWith("..")) continue;
|
|
1117
|
+
onDiskList.push({
|
|
1118
|
+
path: `/${rel}`,
|
|
1119
|
+
size: info.size,
|
|
1120
|
+
mtimeMs: info.mtimeMs,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
console.error(
|
|
1125
|
+
`[dench-fs-daemon] status: could not walk ${args.workspace}: ${
|
|
1126
|
+
error instanceof Error ? error.message : String(error)
|
|
1127
|
+
}`,
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const convexUrl = process.env.CONVEX_URL?.trim();
|
|
1132
|
+
const apiKey = process.env.DENCH_API_KEY?.trim();
|
|
1133
|
+
if (convexUrl && apiKey) {
|
|
1134
|
+
try {
|
|
1135
|
+
const client = new DenchFileClient(convexUrl, apiKey);
|
|
1136
|
+
convexRows = await collectConvexTree(client);
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
console.error(
|
|
1139
|
+
`[dench-fs-daemon] status: convex query failed: ${
|
|
1140
|
+
error instanceof Error ? error.message : String(error)
|
|
1141
|
+
}`,
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const diskByPath = new Map(onDiskList.map((item) => [item.path, item]));
|
|
1147
|
+
const convexFileRows = convexRows.filter((row) => !row.isDir);
|
|
1148
|
+
const convexByPath = new Map(convexFileRows.map((row) => [row.path, row]));
|
|
1149
|
+
|
|
1150
|
+
const drift: DriftItem[] = [];
|
|
1151
|
+
let onlyDisk = 0;
|
|
1152
|
+
let onlyConvex = 0;
|
|
1153
|
+
let hashMismatches = 0;
|
|
1154
|
+
|
|
1155
|
+
for (const item of onDiskList) {
|
|
1156
|
+
const convex = convexByPath.get(item.path);
|
|
1157
|
+
if (!convex) {
|
|
1158
|
+
onlyDisk++;
|
|
1159
|
+
if (drift.length < args.driftLimit) {
|
|
1160
|
+
drift.push({
|
|
1161
|
+
kind: "only_disk",
|
|
1162
|
+
path: item.path,
|
|
1163
|
+
size: item.size,
|
|
1164
|
+
mtimeMs: item.mtimeMs,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (!args.hash) continue;
|
|
1170
|
+
if (!convex.contentHash) continue;
|
|
1171
|
+
try {
|
|
1172
|
+
const bytes = await readFile(`${args.workspace}${item.path}`);
|
|
1173
|
+
const diskHash = sha256(bytes);
|
|
1174
|
+
if (diskHash !== convex.contentHash) {
|
|
1175
|
+
hashMismatches++;
|
|
1176
|
+
if (drift.length < args.driftLimit) {
|
|
1177
|
+
drift.push({
|
|
1178
|
+
kind: "hash_mismatch",
|
|
1179
|
+
path: item.path,
|
|
1180
|
+
diskSize: item.size,
|
|
1181
|
+
diskHashShort: diskHash.slice(0, 8),
|
|
1182
|
+
convexHashShort: convex.contentHash.slice(0, 8),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
} catch {
|
|
1187
|
+
// ignore read failures for a single file
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
for (const row of convexFileRows) {
|
|
1192
|
+
if (!diskByPath.has(row.path)) {
|
|
1193
|
+
onlyConvex++;
|
|
1194
|
+
if (drift.length < args.driftLimit) {
|
|
1195
|
+
drift.push({ kind: "only_convex", path: row.path });
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
let statusFileAgeMs: number | null = null;
|
|
1201
|
+
if (statusFile.exists && statusFile.state) {
|
|
1202
|
+
statusFileAgeMs = Date.now() - statusFile.state.lastUpdatedAt;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
return {
|
|
1206
|
+
daemon: {
|
|
1207
|
+
pidAlive,
|
|
1208
|
+
statusFilePath: DAEMON_STATUS_PATH,
|
|
1209
|
+
statusFileExists: statusFile.exists,
|
|
1210
|
+
statusFileAgeMs,
|
|
1211
|
+
state: statusFile.state,
|
|
1212
|
+
},
|
|
1213
|
+
filesystem: {
|
|
1214
|
+
workspace: args.workspace,
|
|
1215
|
+
fsType: mount.fsType,
|
|
1216
|
+
isFuse: mount.isFuse,
|
|
1217
|
+
source: mount.source,
|
|
1218
|
+
},
|
|
1219
|
+
counts: {
|
|
1220
|
+
onDisk: onDiskList.length,
|
|
1221
|
+
inConvex: convexFileRows.length,
|
|
1222
|
+
onlyOnDisk: onlyDisk,
|
|
1223
|
+
onlyInConvex: onlyConvex,
|
|
1224
|
+
hashMismatches,
|
|
1225
|
+
},
|
|
1226
|
+
drift,
|
|
1227
|
+
recentLogTail: logTail,
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function readStatusFile(): Promise<{
|
|
1232
|
+
exists: boolean;
|
|
1233
|
+
state: DaemonStatus | null;
|
|
1234
|
+
}> {
|
|
1235
|
+
try {
|
|
1236
|
+
const text = await readFile(DAEMON_STATUS_PATH, "utf-8");
|
|
1237
|
+
return { exists: true, state: JSON.parse(text) as DaemonStatus };
|
|
1238
|
+
} catch {
|
|
1239
|
+
return { exists: false, state: null };
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function isPidAlive(pid: number): boolean {
|
|
1244
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1245
|
+
try {
|
|
1246
|
+
process.kill(pid, 0);
|
|
1247
|
+
return true;
|
|
1248
|
+
} catch {
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
async function readLogTail(lines: number): Promise<string[]> {
|
|
1254
|
+
try {
|
|
1255
|
+
const text = await readFile(FS_DAEMON_LOG_PATH, "utf-8");
|
|
1256
|
+
const all = text.split("\n").filter((line) => line.length > 0);
|
|
1257
|
+
return all.slice(-lines);
|
|
1258
|
+
} catch {
|
|
1259
|
+
return [];
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
async function collectConvexTree(client: DenchFileClient): Promise<
|
|
1264
|
+
Array<{ path: string; contentHash?: string; isDir: boolean }>
|
|
1265
|
+
> {
|
|
1266
|
+
// listTree returns one directory's children; recurse into subdirs.
|
|
1267
|
+
const out: Array<{ path: string; contentHash?: string; isDir: boolean }> =
|
|
1268
|
+
[];
|
|
1269
|
+
const queue: string[] = ["/"];
|
|
1270
|
+
const seen = new Set<string>();
|
|
1271
|
+
while (queue.length > 0) {
|
|
1272
|
+
const prefix = queue.shift()!;
|
|
1273
|
+
if (seen.has(prefix)) continue;
|
|
1274
|
+
seen.add(prefix);
|
|
1275
|
+
const rows = await client.listTree(prefix);
|
|
1276
|
+
for (const row of rows) {
|
|
1277
|
+
out.push({
|
|
1278
|
+
path: row.path,
|
|
1279
|
+
contentHash: row.contentHash,
|
|
1280
|
+
isDir: row.isDir,
|
|
1281
|
+
});
|
|
1282
|
+
if (row.isDir) queue.push(row.path);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return out;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function printStatusHuman(report: StatusReport): void {
|
|
1289
|
+
const lines: string[] = [];
|
|
1290
|
+
lines.push("dench-fs-daemon status");
|
|
1291
|
+
lines.push("─".repeat(40));
|
|
1292
|
+
if (report.daemon.state) {
|
|
1293
|
+
const ageSec =
|
|
1294
|
+
report.daemon.statusFileAgeMs !== null
|
|
1295
|
+
? Math.round(report.daemon.statusFileAgeMs / 1000)
|
|
1296
|
+
: null;
|
|
1297
|
+
lines.push(
|
|
1298
|
+
`daemon: ${report.daemon.pidAlive ? "ALIVE" : "DEAD"} (pid ${report.daemon.state.pid})`,
|
|
1299
|
+
);
|
|
1300
|
+
lines.push(
|
|
1301
|
+
`status file: ${ageSec !== null ? `${ageSec}s old` : "missing"}`,
|
|
1302
|
+
);
|
|
1303
|
+
lines.push(`workspace: ${report.filesystem.workspace}`);
|
|
1304
|
+
lines.push(
|
|
1305
|
+
`filesystem: ${report.filesystem.fsType}${report.filesystem.isFuse ? " (FUSE — polling mode)" : ""}`,
|
|
1306
|
+
);
|
|
1307
|
+
if (report.daemon.state.lastReconcile) {
|
|
1308
|
+
const r = report.daemon.state.lastReconcile;
|
|
1309
|
+
const reconcileAge = Math.round((Date.now() - r.finishedAt) / 1000);
|
|
1310
|
+
lines.push(
|
|
1311
|
+
`last reconcile: ${reconcileAge}s ago — observed=${r.observed} synced=${r.scheduledForSync} deleted=${r.deleted} (${r.durationMs}ms)`,
|
|
1312
|
+
);
|
|
1313
|
+
} else {
|
|
1314
|
+
lines.push("last reconcile: never");
|
|
1315
|
+
}
|
|
1316
|
+
if (report.daemon.state.activeBreakers.length > 0) {
|
|
1317
|
+
lines.push(
|
|
1318
|
+
`active breakers: ${report.daemon.state.activeBreakers.length}`,
|
|
1319
|
+
);
|
|
1320
|
+
for (const breaker of report.daemon.state.activeBreakers) {
|
|
1321
|
+
const remain = Math.max(
|
|
1322
|
+
0,
|
|
1323
|
+
Math.round((breaker.resumesAt - Date.now()) / 1000),
|
|
1324
|
+
);
|
|
1325
|
+
lines.push(` • ${breaker.path} (resumes in ${remain}s)`);
|
|
1326
|
+
}
|
|
1327
|
+
} else {
|
|
1328
|
+
lines.push("active breakers: none");
|
|
1329
|
+
}
|
|
1330
|
+
} else {
|
|
1331
|
+
lines.push(
|
|
1332
|
+
`daemon: NOT RUNNING (status file missing at ${report.daemon.statusFilePath})`,
|
|
1333
|
+
);
|
|
1334
|
+
lines.push(`workspace: ${report.filesystem.workspace}`);
|
|
1335
|
+
lines.push(
|
|
1336
|
+
`filesystem: ${report.filesystem.fsType}${report.filesystem.isFuse ? " (FUSE — would use polling)" : ""}`,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
lines.push("");
|
|
1340
|
+
lines.push("file counts");
|
|
1341
|
+
lines.push("─".repeat(40));
|
|
1342
|
+
lines.push(`on disk: ${report.counts.onDisk}`);
|
|
1343
|
+
lines.push(`in convex: ${report.counts.inConvex}`);
|
|
1344
|
+
lines.push(`only on disk: ${report.counts.onlyOnDisk}`);
|
|
1345
|
+
lines.push(`only in convex: ${report.counts.onlyInConvex}`);
|
|
1346
|
+
lines.push(`hash mismatches: ${report.counts.hashMismatches}`);
|
|
1347
|
+
|
|
1348
|
+
if (report.drift.length > 0) {
|
|
1349
|
+
lines.push("");
|
|
1350
|
+
lines.push(`drift (first ${report.drift.length})`);
|
|
1351
|
+
lines.push("─".repeat(40));
|
|
1352
|
+
for (const item of report.drift) {
|
|
1353
|
+
if (item.kind === "only_disk") {
|
|
1354
|
+
lines.push(` + ${item.path} ${item.size}B (only on disk)`);
|
|
1355
|
+
} else if (item.kind === "only_convex") {
|
|
1356
|
+
lines.push(` - ${item.path} (only in convex)`);
|
|
1357
|
+
} else {
|
|
1358
|
+
lines.push(
|
|
1359
|
+
` ! ${item.path} disk=${item.diskHashShort}… convex=${item.convexHashShort}…`,
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (report.recentLogTail.length > 0) {
|
|
1366
|
+
lines.push("");
|
|
1367
|
+
lines.push("recent log tail");
|
|
1368
|
+
lines.push("─".repeat(40));
|
|
1369
|
+
for (const line of report.recentLogTail) lines.push(line);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
623
1375
|
/**
|
|
624
1376
|
* One-shot recursive sync. Walks the workspace + uploads every file to
|
|
625
1377
|
* Convex Storage / commitFileUpload. Used by the migration script
|
|
@@ -699,16 +1451,38 @@ async function runInitialSync(argv: string[]): Promise<void> {
|
|
|
699
1451
|
);
|
|
700
1452
|
}
|
|
701
1453
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1454
|
+
/**
|
|
1455
|
+
* Only auto-run when this file is the entry point. Tests import named
|
|
1456
|
+
* exports (`readMountInfo`, `findMountForPath`, `isFuseFsType`,
|
|
1457
|
+
* etc.) and must not trigger the daemon's main loop. The check below
|
|
1458
|
+
* tolerates Bun (`process.argv[1]`), the npm-installed wrapper
|
|
1459
|
+
* (`require.main === module`), and the dench CLI's `tsx`-based
|
|
1460
|
+
* fallback (`import.meta.main`).
|
|
1461
|
+
*/
|
|
1462
|
+
const isDirectEntrypoint = (() => {
|
|
1463
|
+
try {
|
|
1464
|
+
if (typeof require !== "undefined" && require.main === module) return true;
|
|
1465
|
+
} catch {
|
|
1466
|
+
// require / module aren't defined in pure ESM mode; fall through.
|
|
712
1467
|
}
|
|
713
|
-
|
|
714
|
-
|
|
1468
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun-specific import.meta property
|
|
1469
|
+
if ((import.meta as any).main === true) return true;
|
|
1470
|
+
const entry = process.argv[1] ?? "";
|
|
1471
|
+
return entry.endsWith("fs-daemon.ts") || entry.endsWith("/fs-daemon");
|
|
1472
|
+
})();
|
|
1473
|
+
|
|
1474
|
+
if (isDirectEntrypoint) {
|
|
1475
|
+
main().catch((error) => {
|
|
1476
|
+
console.error(
|
|
1477
|
+
`[dench-fs-daemon] fatal: ${
|
|
1478
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error)
|
|
1479
|
+
}`,
|
|
1480
|
+
);
|
|
1481
|
+
// Always exit with a non-zero code so the supervisor (in
|
|
1482
|
+
// src/lib/daytona/sandbox-bootstrap.ts) can decide whether to
|
|
1483
|
+
// restart. Previously we parked the process on a noop interval,
|
|
1484
|
+
// which made `pgrep dench-fs-daemon` report "alive" for a daemon
|
|
1485
|
+
// that had silently given up syncing.
|
|
1486
|
+
process.exit(1);
|
|
1487
|
+
});
|
|
1488
|
+
}
|