@dench.com/cli 0.4.2 → 0.4.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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Binary-content detection for `dench-fs-daemon`.
3
+ *
4
+ * Background: the daemon's `commitFileUpload` historically inlined every
5
+ * file ≤ `MAX_INLINE_TEXT_BYTES` (100 KB) into `fileContents.text` via
6
+ * `TextDecoder("utf-8", { fatal: false }).decode(bytes)`. The
7
+ * `fatal: false` mode silently substitutes U+FFFD (replacement char)
8
+ * for any invalid byte sequence — perfect for ad-hoc text rendering,
9
+ * disastrous when a small PNG / PDF / .zip lands in the cache:
10
+ * • The storage blob is fine (bytes preserved verbatim in `_storage`).
11
+ * • The inline text cache is corrupt (replacement chars everywhere).
12
+ * • The viewer that pulls from `fileContents.text` for fast paths
13
+ * renders garbled mojibake until something forces a re-write.
14
+ *
15
+ * The agent's `commitConvexFileBytes` in chat-turn.ts has had a sibling
16
+ * `detectBinaryBuffer` for a while — this file ports it verbatim so
17
+ * the daemon can route binary blobs to the no-inline path. Keeping it
18
+ * in its own module (no chokidar / Convex deps) means the unit tests
19
+ * can pin behavior without booting the daemon.
20
+ */
21
+
22
+ const BINARY_PROBE_BYTES = 4096;
23
+
24
+ /**
25
+ * Sniff a buffer for binary content. PNG / PDF / Office / .zip all
26
+ * contain NUL bytes within the first KB; valid UTF-8 text never does.
27
+ * We deliberately limit the scan to the first 4 KiB so the check stays
28
+ * cheap on multi-MB blobs, and treat invalid UTF-8 in that prefix as a
29
+ * binary signal too — silently round-tripping garbage Unicode through
30
+ * the inline-text cache is what corrupts the rendered text for
31
+ * downstream viewers.
32
+ *
33
+ * Returns `true` when the buffer looks binary (skip inline text cache),
34
+ * `false` when it looks like clean UTF-8 text (safe to inline).
35
+ */
36
+ export function detectBinaryBuffer(buffer: Uint8Array): boolean {
37
+ const slice =
38
+ buffer.byteLength > BINARY_PROBE_BYTES
39
+ ? buffer.subarray(0, BINARY_PROBE_BYTES)
40
+ : buffer;
41
+ for (const byte of slice) {
42
+ if (byte === 0) return true;
43
+ }
44
+ try {
45
+ new TextDecoder("utf-8", { fatal: true }).decode(slice);
46
+ return false;
47
+ } catch {
48
+ return true;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Convenience: decode bytes to a UTF-8 string ONLY when the buffer
54
+ * looks like clean text. Returns `undefined` for binary content so
55
+ * callers can pass it straight into `commitFileUpload({ text })` —
56
+ * Convex schema marks `text` optional and skips the inline cache when
57
+ * it's missing.
58
+ */
59
+ export function decodeInlineTextOrUndefined(
60
+ buffer: Uint8Array,
61
+ ): string | undefined {
62
+ if (detectBinaryBuffer(buffer)) return undefined;
63
+ return new TextDecoder("utf-8", { fatal: false }).decode(buffer);
64
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pure polling loop for `dench-fs-daemon flush`.
3
+ *
4
+ * Lives in its own module so the unit tests can pull `runFlushPolling`
5
+ * without importing `cli/fs-daemon.ts`'s top-level `main()` (which
6
+ * would try to start the actual daemon and crash the test runner).
7
+ *
8
+ * The flush command's whole job is to make `execute_bash` writes
9
+ * deterministically visible in Convex BEFORE chat-turn returns the
10
+ * tool result to the model. The polling loop is responsible for:
11
+ *
12
+ * 1. Bouncing immediately when no daemon is registered or the
13
+ * registered pid is dead. Otherwise chat-turn would hang for
14
+ * timeoutMs every turn on a host where the daemon never came up.
15
+ * 2. Sending exactly one SIGUSR1 (the daemon's runDaemon registers a
16
+ * handler that triggers an immediate reconcile tick).
17
+ * 3. Treating the flush as complete only when BOTH conditions hold:
18
+ * a) `lastReconcile.finishedAt > t0` (proves the signal-driven
19
+ * tick actually ran and not just an old scheduled tick).
20
+ * b) `pendingDrain.{pendingPaths, inFlightUploads}` are both 0
21
+ * (proves every per-path debounce timer fired AND the upload
22
+ * it triggered drained — without (b) we'd race a pending
23
+ * 300 ms debounce or an in-flight Convex Storage POST).
24
+ * 4. Surfacing a structured timeout result with the last-seen drain
25
+ * counts so the caller (chat-turn) can decide whether to flag
26
+ * drift to the user.
27
+ */
28
+
29
+ /**
30
+ * Snapshot of how much work the daemon still has in flight. The
31
+ * `flush` subcommand polls this via the status file and only declares
32
+ * success when both numbers are zero AND a fresh reconcile has
33
+ * completed since the flush request.
34
+ */
35
+ export type DrainStatus = {
36
+ /** Paths with a debounce timer set but the upload hasn't started. */
37
+ pendingPaths: number;
38
+ /** Upload calls currently mid-flight (between fn() start and finally). */
39
+ inFlightUploads: number;
40
+ };
41
+
42
+ /**
43
+ * Subset of the daemon status file the flush poller needs. The full
44
+ * shape lives in `cli/fs-daemon.ts`; we accept any superset here so
45
+ * upstream changes to the status file don't ripple into this module.
46
+ */
47
+ export type FlushPollingStatus = {
48
+ pid: number;
49
+ lastReconcile: { finishedAt: number } | null;
50
+ pendingDrain: DrainStatus;
51
+ };
52
+
53
+ export type FlushOutcome =
54
+ | { ok: true; reason: "flushed"; pid: number; durationMs: number }
55
+ | { ok: false; reason: "no_daemon"; pid: number | null; durationMs: number }
56
+ | {
57
+ ok: false;
58
+ reason: "signal_failed";
59
+ pid: number;
60
+ durationMs: number;
61
+ error: string;
62
+ }
63
+ | {
64
+ ok: false;
65
+ reason: "timeout";
66
+ pid: number;
67
+ durationMs: number;
68
+ drain: DrainStatus;
69
+ };
70
+
71
+ export const FLUSH_POLL_INTERVAL_MS = 50;
72
+ export const FLUSH_DEFAULT_TIMEOUT_MS = 5_000;
73
+
74
+ export type FlushPollingArgs = {
75
+ timeoutMs: number;
76
+ readStatus: () => Promise<FlushPollingStatus | null>;
77
+ isPidAlive: (pid: number) => boolean;
78
+ signal: (pid: number) => void;
79
+ now?: () => number;
80
+ sleep?: (ms: number) => Promise<void>;
81
+ pollIntervalMs?: number;
82
+ };
83
+
84
+ export async function runFlushPolling(
85
+ args: FlushPollingArgs,
86
+ ): Promise<FlushOutcome> {
87
+ const now = args.now ?? (() => Date.now());
88
+ const sleep =
89
+ args.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
90
+ const pollMs = args.pollIntervalMs ?? FLUSH_POLL_INTERVAL_MS;
91
+ const t0 = now();
92
+
93
+ const initial = await args.readStatus();
94
+ if (!initial) {
95
+ return {
96
+ ok: false,
97
+ reason: "no_daemon",
98
+ pid: null,
99
+ durationMs: now() - t0,
100
+ };
101
+ }
102
+ const pid = initial.pid;
103
+ if (!args.isPidAlive(pid)) {
104
+ return { ok: false, reason: "no_daemon", pid, durationMs: now() - t0 };
105
+ }
106
+ try {
107
+ args.signal(pid);
108
+ } catch (error) {
109
+ return {
110
+ ok: false,
111
+ reason: "signal_failed",
112
+ pid,
113
+ durationMs: now() - t0,
114
+ error: error instanceof Error ? error.message : String(error),
115
+ };
116
+ }
117
+
118
+ const deadline = t0 + args.timeoutMs;
119
+ let lastDrain: DrainStatus = initial.pendingDrain;
120
+ while (now() < deadline) {
121
+ await sleep(pollMs);
122
+ const current = await args.readStatus();
123
+ if (!current) continue;
124
+ lastDrain = current.pendingDrain;
125
+ const finishedAfterT0 = (current.lastReconcile?.finishedAt ?? 0) > t0;
126
+ const drained =
127
+ current.pendingDrain.pendingPaths === 0 &&
128
+ current.pendingDrain.inFlightUploads === 0;
129
+ if (finishedAfterT0 && drained) {
130
+ return { ok: true, reason: "flushed", pid, durationMs: now() - t0 };
131
+ }
132
+ }
133
+ return {
134
+ ok: false,
135
+ reason: "timeout",
136
+ pid,
137
+ durationMs: now() - t0,
138
+ drain: lastDrain,
139
+ };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,8 @@
13
13
  "dench.ts",
14
14
  "fs-daemon",
15
15
  "fs-daemon.ts",
16
+ "fs-daemon-binary.ts",
17
+ "fs-daemon-flush.ts",
16
18
  "fs-daemon-mount.ts",
17
19
  "agent.ts",
18
20
  "chat.ts",