@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.
- package/fs-daemon-binary.ts +64 -0
- package/fs-daemon-flush.ts +140 -0
- package/package.json +3 -1
|
@@ -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.
|
|
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",
|