@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/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 emit a runEvents warning.
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 { mkdir, readFile, stat, writeFile } from "node:fs/promises";
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 Set<string>();
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
- this.paused.add(absPath);
142
- console.warn(
143
- `[dench-fs-daemon] circuit breaker tripped for ${absPath}; pausing sync for 60s`,
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
- }, 60_000);
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; nothing to sync",
481
+ "[dench-fs-daemon] CONVEX_URL or DENCH_API_KEY missing in env; refusing to start",
368
482
  );
369
- setInterval(() => undefined, 60_000);
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; running in idle mode (${
519
+ `[dench-fs-daemon] chokidar not available; refusing to start (${
390
520
  error instanceof Error ? error.message : String(error)
391
521
  })`,
392
522
  );
393
- setInterval(() => undefined, 60_000);
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
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
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(`[dench-fs-daemon] watching ${daemonArgs.workspace}`);
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
- main().catch((error) => {
703
- console.error(
704
- `[dench-fs-daemon] fatal: ${
705
- error instanceof Error ? (error.stack ?? error.message) : String(error)
706
- }`,
707
- );
708
- // Long-running daemon: don't exit on transient errors. One-shot
709
- // subcommands use a non-zero exit so callers see failure.
710
- if (process.argv.slice(2)[0]?.startsWith("stream-")) {
711
- process.exit(1);
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
- setInterval(() => undefined, 60_000);
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
+ }