@dench.com/cli 0.4.1 → 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/chat-spawn.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * calls until the workflow finishes.
18
18
  */
19
19
 
20
+ import { getCurrentRuntimeTimeZone } from "@/lib/timezone";
20
21
  import { hasFlag } from "./lib/cli-args";
21
22
 
22
23
  class ChatSpawnError extends Error {}
@@ -64,12 +65,14 @@ async function postJson(
64
65
  bearer: string,
65
66
  body: unknown,
66
67
  ): Promise<{ ok: boolean; status: number; payload: unknown }> {
68
+ const timezone = getCurrentRuntimeTimeZone();
67
69
  const response = await fetch(url, {
68
70
  method: "POST",
69
71
  headers: {
70
72
  accept: "application/json",
71
73
  "content-type": "application/json",
72
74
  authorization: `Bearer ${bearer}`,
75
+ ...(timezone ? { "x-dench-user-timezone": timezone } : {}),
73
76
  },
74
77
  body: JSON.stringify(body),
75
78
  });
@@ -258,7 +261,10 @@ async function followStream(args: {
258
261
  }
259
262
 
260
263
  function joinPositionalsExceptFlags(args: string[]): string {
261
- return args.filter((arg) => !arg.startsWith("--")).join(" ").trim();
264
+ return args
265
+ .filter((arg) => !arg.startsWith("--"))
266
+ .join(" ")
267
+ .trim();
262
268
  }
263
269
 
264
270
  async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
@@ -286,6 +292,7 @@ async function runChatNew(ctx: ChatSpawnContext): Promise<void> {
286
292
  visibility,
287
293
  title,
288
294
  yolo,
295
+ timezone: getCurrentRuntimeTimeZone(),
289
296
  },
290
297
  );
291
298
  if (!result.ok) {
@@ -341,6 +348,7 @@ async function runChatSend(ctx: ChatSpawnContext): Promise<void> {
341
348
  prompt,
342
349
  model,
343
350
  yolo,
351
+ timezone: getCurrentRuntimeTimeZone(),
344
352
  },
345
353
  );
346
354
  if (!result.ok) {
@@ -385,9 +393,7 @@ async function runChatFollow(ctx: ChatSpawnContext): Promise<void> {
385
393
  const positionals = ctx.args.filter((arg) => !arg.startsWith("--"));
386
394
  const threadId = positionals.shift();
387
395
  if (!threadId) {
388
- throw new ChatSpawnError(
389
- "Usage: dench chat follow <threadId> [--json]",
390
- );
396
+ throw new ChatSpawnError("Usage: dench chat follow <threadId> [--json]");
391
397
  }
392
398
  if (!ctx.jsonOutput) {
393
399
  console.error(`--- following stream for thread ${threadId} ---`);
package/crm.ts CHANGED
@@ -17,8 +17,8 @@
17
17
  * The full surface mirrors the plan's "dench-cli CRM surface" section:
18
18
  * crm objects list / get / create / update / delete / rename
19
19
  * crm fields list / create / update / delete / reorder
20
- * crm entries list / get / create / update / delete / bulk-delete
21
- * crm cells get / set / append
20
+ * crm entries list / get / create / create-many / update / update-many / delete / bulk-delete
21
+ * crm cells get / set / set-many / append
22
22
  * crm query <object> --where … --select … --sort … --limit …
23
23
  * crm aggregate <object> --field …
24
24
  * crm batch --file ops.jsonl
@@ -40,6 +40,8 @@ import {
40
40
 
41
41
  type JsonRecord = Record<string, unknown>;
42
42
 
43
+ const CRM_BATCH_CHUNK_SIZE = 200;
44
+
43
45
  type CrmCliContext = {
44
46
  convex: ConvexHttpClient;
45
47
  args: string[];
@@ -97,6 +99,150 @@ function getDataFlag(args: string[]): string | undefined {
97
99
  return getFlagWithAliases(args, "--data", ["--fields"]);
98
100
  }
99
101
 
102
+ function getUpdatesFlag(args: string[]): string | undefined {
103
+ return getFlagWithAliases(args, "--updates", ["--data"]);
104
+ }
105
+
106
+ function isJsonRecord(value: unknown): value is JsonRecord {
107
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
108
+ }
109
+
110
+ function requireNonEmptyJsonRecord(value: unknown, label: string): JsonRecord {
111
+ if (!isJsonRecord(value) || Object.keys(value).length === 0) {
112
+ throw new CrmCliError(`${label} must be a non-empty JSON object`);
113
+ }
114
+ return value;
115
+ }
116
+
117
+ async function readJsonArrayInput(opts: {
118
+ args: string[];
119
+ command: string;
120
+ inlineFlag: string;
121
+ inlineValue: string | undefined;
122
+ }): Promise<unknown[]> {
123
+ const filePath = getFlag(opts.args, "--file");
124
+ assertNoUnknownFlags(opts.args, opts.command);
125
+ if (opts.inlineValue !== undefined && filePath) {
126
+ throw new CrmCliError(
127
+ `${opts.command} accepts either ${opts.inlineFlag} or --file, not both`,
128
+ );
129
+ }
130
+ if (opts.inlineValue === undefined && !filePath) {
131
+ throw new CrmCliError(
132
+ `${opts.command} requires ${opts.inlineFlag} '<json-array>' or --file <path>`,
133
+ );
134
+ }
135
+
136
+ let raw: string;
137
+ if (filePath) {
138
+ const fs = await import("node:fs/promises");
139
+ raw = await fs.readFile(filePath, "utf-8");
140
+ } else {
141
+ raw = opts.inlineValue ?? "";
142
+ }
143
+
144
+ const parsed = parseJson(raw);
145
+ if (!Array.isArray(parsed)) {
146
+ throw new CrmCliError(`${opts.inlineFlag} must be a JSON array`);
147
+ }
148
+ if (parsed.length === 0) {
149
+ throw new CrmCliError(`${opts.inlineFlag} must include at least one item`);
150
+ }
151
+ return parsed;
152
+ }
153
+
154
+ function parseCreateManyRows(items: unknown[]): JsonRecord[] {
155
+ return items.map((item, index) =>
156
+ requireNonEmptyJsonRecord(item, `create-many item ${index + 1}`),
157
+ );
158
+ }
159
+
160
+ function parseUpdateManyRows(items: unknown[]): Array<{
161
+ id: string;
162
+ data: JsonRecord;
163
+ }> {
164
+ return items.map((item, index) => {
165
+ if (!isJsonRecord(item) || typeof item.id !== "string" || !item.id) {
166
+ throw new CrmCliError(
167
+ `update-many item ${index + 1} must include a string id`,
168
+ );
169
+ }
170
+ return {
171
+ id: item.id,
172
+ data: requireNonEmptyJsonRecord(
173
+ item.data,
174
+ `update-many item ${index + 1} data`,
175
+ ),
176
+ };
177
+ });
178
+ }
179
+
180
+ function parseCellSetManyRows(items: unknown[]): Array<{
181
+ entryId: string;
182
+ field: string;
183
+ value: unknown;
184
+ }> {
185
+ return items.map((item, index) => {
186
+ if (!isJsonRecord(item)) {
187
+ throw new CrmCliError(
188
+ `cells set-many item ${index + 1} must be a JSON object`,
189
+ );
190
+ }
191
+ if (typeof item.entryId !== "string" || !item.entryId) {
192
+ throw new CrmCliError(
193
+ `cells set-many item ${index + 1} must include a string entryId`,
194
+ );
195
+ }
196
+ if (typeof item.field !== "string" || !item.field) {
197
+ throw new CrmCliError(
198
+ `cells set-many item ${index + 1} must include a string field`,
199
+ );
200
+ }
201
+ return {
202
+ entryId: item.entryId,
203
+ field: item.field,
204
+ value: item.value ?? null,
205
+ };
206
+ });
207
+ }
208
+
209
+ type CrmBatchOp =
210
+ | { type: "entries.create"; objectName: string; data: JsonRecord }
211
+ | { type: "entries.update"; entryId: string; data: JsonRecord };
212
+
213
+ function chunkArray<T>(items: T[], size: number): T[][] {
214
+ const chunks: T[][] = [];
215
+ for (let index = 0; index < items.length; index += size) {
216
+ chunks.push(items.slice(index, index + size));
217
+ }
218
+ return chunks;
219
+ }
220
+
221
+ function chunkIdempotencyKey(
222
+ idempotencyKey: string | undefined,
223
+ chunkIndex: number,
224
+ ): string | undefined {
225
+ return idempotencyKey
226
+ ? `${idempotencyKey}:chunk-${chunkIndex + 1}`
227
+ : undefined;
228
+ }
229
+
230
+ async function applyBatchOpsInChunks(
231
+ ctx: CrmCliContext,
232
+ ops: CrmBatchOp[],
233
+ idempotencyKey: string | undefined,
234
+ ): Promise<number> {
235
+ const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
236
+ for (const [chunkIndex, chunk] of chunks.entries()) {
237
+ const chunkKey = chunkIdempotencyKey(idempotencyKey, chunkIndex);
238
+ await callMutation(ctx, api.batch.apply, {
239
+ ops: chunk,
240
+ ...(chunkKey ? { idempotencyKey: chunkKey } : {}),
241
+ });
242
+ }
243
+ return chunks.length;
244
+ }
245
+
100
246
  async function callQuery(
101
247
  ctx: CrmCliContext,
102
248
  fn: Parameters<ConvexHttpClient["query"]>[0],
@@ -139,6 +285,35 @@ function out(ctx: CrmCliContext, value: unknown): void {
139
285
  console.log(JSON.stringify(value, null, 2));
140
286
  }
141
287
 
288
+ function getFieldEnrichmentFlag(args: string[]): {
289
+ category: EnrichmentCategory;
290
+ key: string;
291
+ apolloPath: string;
292
+ inputFieldName: string;
293
+ } | undefined {
294
+ const category = parseCategoryFlag(getFlag(args, "--enrichment-category"));
295
+ const key = getFlag(args, "--enrichment-key");
296
+ const apolloPath = getFlag(args, "--apollo-path");
297
+ const inputFieldName = getFlag(args, "--input-field");
298
+ const sawAny =
299
+ category !== undefined ||
300
+ key !== undefined ||
301
+ apolloPath !== undefined ||
302
+ inputFieldName !== undefined;
303
+ if (!sawAny) return undefined;
304
+ if (!category || !apolloPath || !inputFieldName) {
305
+ throw new CrmCliError(
306
+ "Enrichment fields require --enrichment-category people|company, --apollo-path <path>, and --input-field <field>.",
307
+ );
308
+ }
309
+ return {
310
+ category,
311
+ key: key ?? apolloPath,
312
+ apolloPath,
313
+ inputFieldName,
314
+ };
315
+ }
316
+
142
317
  const api = {
143
318
  transaction: {
144
319
  begin: makeFunctionReference<"mutation">("functions/crm/transaction:begin"),
@@ -380,6 +555,7 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
380
555
  : hasFlag(ctx.args, "--no-indexed")
381
556
  ? false
382
557
  : undefined;
558
+ const enrichment = getFieldEnrichmentFlag(ctx.args);
383
559
  assertNoUnknownFlags(ctx.args, "crm fields create");
384
560
  out(
385
561
  ctx,
@@ -395,6 +571,7 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
395
571
  relatedObjectName,
396
572
  relationshipType,
397
573
  indexed,
574
+ enrichment,
398
575
  }),
399
576
  );
400
577
  return;
@@ -412,6 +589,8 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
412
589
  : hasFlag(ctx.args, "--no-indexed")
413
590
  ? false
414
591
  : undefined;
592
+ const enrichment = getFieldEnrichmentFlag(ctx.args);
593
+ const clearEnrichment = hasFlag(ctx.args, "--clear-enrichment");
415
594
  assertNoUnknownFlags(ctx.args, "crm fields update");
416
595
  out(
417
596
  ctx,
@@ -422,6 +601,8 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
422
601
  required,
423
602
  enumValues,
424
603
  indexed,
604
+ enrichment,
605
+ clearEnrichment,
425
606
  }),
426
607
  );
427
608
  return;
@@ -509,6 +690,30 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
509
690
  );
510
691
  return;
511
692
  }
693
+ case "create-many": {
694
+ const objectName = shift(ctx.args, "object name");
695
+ const dataRaw = getDataFlag(ctx.args);
696
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
697
+ const rows = parseCreateManyRows(
698
+ await readJsonArrayInput({
699
+ args: ctx.args,
700
+ command: "crm entries create-many",
701
+ inlineFlag: "--data",
702
+ inlineValue: dataRaw,
703
+ }),
704
+ );
705
+ const chunks = await applyBatchOpsInChunks(
706
+ ctx,
707
+ rows.map((data) => ({
708
+ type: "entries.create",
709
+ objectName,
710
+ data,
711
+ })),
712
+ idempotencyKey,
713
+ );
714
+ out(ctx, { created: rows.length, chunks });
715
+ return;
716
+ }
512
717
  case "update": {
513
718
  const objectName = shift(ctx.args, "object name");
514
719
  const entryId = shift(ctx.args, "entry id");
@@ -535,6 +740,30 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
535
740
  );
536
741
  return;
537
742
  }
743
+ case "update-many": {
744
+ const _objectName = shift(ctx.args, "object name");
745
+ const updatesRaw = getUpdatesFlag(ctx.args);
746
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
747
+ const updates = parseUpdateManyRows(
748
+ await readJsonArrayInput({
749
+ args: ctx.args,
750
+ command: "crm entries update-many",
751
+ inlineFlag: "--updates",
752
+ inlineValue: updatesRaw,
753
+ }),
754
+ );
755
+ const chunks = await applyBatchOpsInChunks(
756
+ ctx,
757
+ updates.map((update) => ({
758
+ type: "entries.update",
759
+ entryId: update.id,
760
+ data: update.data,
761
+ })),
762
+ idempotencyKey,
763
+ );
764
+ out(ctx, { updated: updates.length, chunks });
765
+ return;
766
+ }
538
767
  case "delete":
539
768
  case "remove": {
540
769
  const _objectName = shift(ctx.args, "object name");
@@ -569,11 +798,11 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
569
798
 
570
799
  async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
571
800
  const verb = ctx.args.shift();
572
- const objectName = shift(ctx.args, "object name");
573
- const entryId = shift(ctx.args, "entry id");
574
- const fieldName = shift(ctx.args, "field name");
575
801
  switch (verb) {
576
802
  case "get": {
803
+ const objectName = shift(ctx.args, "object name");
804
+ const entryId = shift(ctx.args, "entry id");
805
+ const fieldName = shift(ctx.args, "field name");
577
806
  assertNoUnknownFlags(ctx.args, "crm cells get");
578
807
  out(
579
808
  ctx,
@@ -586,6 +815,9 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
586
815
  return;
587
816
  }
588
817
  case "set": {
818
+ const objectName = shift(ctx.args, "object name");
819
+ const entryId = shift(ctx.args, "entry id");
820
+ const fieldName = shift(ctx.args, "field name");
589
821
  const value = parseJson(ctx.args.shift()) ?? null;
590
822
  assertNoUnknownFlags(ctx.args, "crm cells set");
591
823
  out(
@@ -599,7 +831,39 @@ async function runCellsCommand(ctx: CrmCliContext): Promise<void> {
599
831
  );
600
832
  return;
601
833
  }
834
+ case "set-many": {
835
+ const _objectName = shift(ctx.args, "object name");
836
+ const updatesRaw = getUpdatesFlag(ctx.args);
837
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
838
+ const updates = parseCellSetManyRows(
839
+ await readJsonArrayInput({
840
+ args: ctx.args,
841
+ command: "crm cells set-many",
842
+ inlineFlag: "--updates",
843
+ inlineValue: updatesRaw,
844
+ }),
845
+ );
846
+ const grouped = new Map<string, JsonRecord>();
847
+ for (const update of updates) {
848
+ const data = grouped.get(update.entryId) ?? {};
849
+ data[update.field] = update.value;
850
+ grouped.set(update.entryId, data);
851
+ }
852
+ const ops: CrmBatchOp[] = Array.from(grouped.entries()).map(
853
+ ([entryId, data]) => ({
854
+ type: "entries.update",
855
+ entryId,
856
+ data,
857
+ }),
858
+ );
859
+ const chunks = await applyBatchOpsInChunks(ctx, ops, idempotencyKey);
860
+ out(ctx, { updated: updates.length, entriesUpdated: ops.length, chunks });
861
+ return;
862
+ }
602
863
  case "append": {
864
+ const objectName = shift(ctx.args, "object name");
865
+ const entryId = shift(ctx.args, "entry id");
866
+ const fieldName = shift(ctx.args, "field name");
603
867
  const value = parseJson(ctx.args.shift()) ?? null;
604
868
  assertNoUnknownFlags(ctx.args, "crm cells append");
605
869
  out(
@@ -1431,8 +1695,9 @@ Fields (columns):
1431
1695
  dench crm fields create <object> <name> --type text|email|phone|url|number|boolean|date|enum|relation|user|tags|action|file|richtext \\
1432
1696
  [--required] [--description ...] [--enum-values JSON] [--enum-colors JSON] \\
1433
1697
  [--enum-multiple] [--related-object <name>] [--relationship many_to_one|many_to_many] \\
1434
- [--indexed | --no-indexed]
1435
- dench crm fields update <object> <field> [--description ...] [--required] [--enum-values JSON] [--indexed | --no-indexed]
1698
+ [--indexed | --no-indexed] [--enrichment-category people|company --apollo-path <path> --input-field <field>]
1699
+ dench crm fields update <object> <field> [--description ...] [--required] [--enum-values JSON] [--indexed | --no-indexed] \\
1700
+ [--enrichment-category people|company --apollo-path <path> --input-field <field> | --clear-enrichment]
1436
1701
  dench crm fields delete <object> <field>
1437
1702
  dench crm fields reorder <object> <field1> <field2> <field3> ...
1438
1703
 
@@ -1440,7 +1705,9 @@ Entries (rows):
1440
1705
  dench crm entries list <object> [--limit N] [--json]
1441
1706
  dench crm entries get <object> <entryId>
1442
1707
  dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields)
1708
+ dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>]
1443
1709
  dench crm entries update <object> <entryId> --data '{...}' (alias: --fields)
1710
+ dench crm entries update-many <object> --updates '[{"id":"...","data":{...}}]' [--file updates.json] [--idempotency-key <key>]
1444
1711
  dench crm entries delete <object> <entryId>
1445
1712
  dench crm entries bulk-delete <object> --ids id1,id2,id3
1446
1713
  Note: --data is REQUIRED on create/update and must include >=1 field.
@@ -1450,6 +1717,7 @@ Entries (rows):
1450
1717
  Cells (single-field updates):
1451
1718
  dench crm cells get <object> <entryId> <field>
1452
1719
  dench crm cells set <object> <entryId> <field> '<JSON value>'
1720
+ dench crm cells set-many <object> --updates '[{"entryId":"...","field":"Status","value":"Done"}]' [--file updates.json] [--idempotency-key <key>]
1453
1721
  dench crm cells append <object> <entryId> <field> '<JSON value>'
1454
1722
 
1455
1723
  Query DSL:
@@ -1467,6 +1735,9 @@ Statuses (kanban columns):
1467
1735
 
1468
1736
  Batch:
1469
1737
  dench crm batch --file ops.jsonl [--idempotency-key <key>]
1738
+ For repeated writes, prefer entries create-many/update-many or cells set-many;
1739
+ for transformations, write a script in /tmp/work/<runId>/ that loops locally
1740
+ and calls a batch surface in chunks.
1470
1741
 
1471
1742
  Import / export:
1472
1743
  dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error]
package/dench.ts CHANGED
@@ -447,8 +447,9 @@ Usage:
447
447
  CRM (Daytona-native rewrite):
448
448
  dench crm objects <list|get|create|update|rename|delete> ...
449
449
  dench crm fields <list|create|update|delete|reorder> <object> ...
450
- dench crm entries <list|get|create|update|delete|bulk-delete> <object> ...
450
+ dench crm entries <list|get|create|create-many|update|update-many|delete|bulk-delete> <object> ...
451
451
  dench crm cells <get|set|append> <object> <entryId> <field> [value]
452
+ dench crm cells set-many <object> --updates '[...]'
452
453
  dench crm query <object> --where '...' --select ... --sort ... --limit N
453
454
  dench crm sql 'SELECT "Name" FROM lead WHERE ... LIMIT 10'
454
455
  dench crm aggregate <object> --field <name>
@@ -462,6 +463,7 @@ CRM (Daytona-native rewrite):
462
463
  dench crm docs <list|create|link>
463
464
  dench crm actions <list|run> ...
464
465
  dench crm batch --file ops.jsonl
466
+ Batch write helpers: entries create-many/update-many, cells set-many
465
467
  Help: dench crm help
466
468
 
467
469
  Live web search (Exa via the Dench Cloud Gateway, auths with DENCH_API_KEY):
@@ -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/fs-daemon.ts CHANGED
@@ -50,16 +50,16 @@
50
50
  */
51
51
  import { createHash } from "node:crypto";
52
52
  import { mkdirSync } from "node:fs";
53
- import {
54
- mkdir,
55
- readdir,
56
- readFile,
57
- stat,
58
- writeFile,
59
- } from "node:fs/promises";
53
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
60
54
  import { dirname, relative } from "node:path";
61
55
  import { ConvexClient, ConvexHttpClient } from "convex/browser";
62
56
  import { makeFunctionReference } from "convex/server";
57
+ import { decodeInlineTextOrUndefined } from "./fs-daemon-binary";
58
+ import {
59
+ type DrainStatus,
60
+ FLUSH_DEFAULT_TIMEOUT_MS,
61
+ runFlushPolling,
62
+ } from "./fs-daemon-flush";
63
63
  import {
64
64
  detectWorkspaceFsType,
65
65
  findMountForPath,
@@ -156,12 +156,20 @@ type BreakerState = {
156
156
  syncsInWindow: number;
157
157
  };
158
158
 
159
+ // `DrainStatus` lives in cli/fs-daemon-flush.ts so the flush subcommand's
160
+ // polling helper can import it without dragging in the daemon's main
161
+ // entrypoint (which would crash test runners that import this file).
162
+
159
163
  class HashTracker {
160
164
  private hashes = new Map<string, string>();
161
165
  private timers = new Map<string, NodeJS.Timeout>();
162
166
  private recentSyncs = new Map<string, number[]>();
163
167
  private paused = new Map<string, BreakerState>();
164
168
  private onBreakerTrip?: (state: BreakerState) => void;
169
+ // Bumped right before each scheduled fn() actually starts; decremented
170
+ // in the fn()'s `.finally()`. The flush subcommand reads this through
171
+ // `drainStatus()` (mirrored into the status file once per reconcile).
172
+ private inFlightUploads = 0;
165
173
 
166
174
  get(absPath: string): string | undefined {
167
175
  return this.hashes.get(absPath);
@@ -237,16 +245,37 @@ class HashTracker {
237
245
  if (existing) clearTimeout(existing);
238
246
  const timer = setTimeout(() => {
239
247
  this.timers.delete(absPath);
240
- fn().catch((error) => {
241
- console.error(
242
- `[dench-fs-daemon] sync failed for ${absPath}: ${
243
- error instanceof Error ? error.message : String(error)
244
- }`,
245
- );
246
- });
248
+ // Track this work in-flight so `flush` knows to wait for the
249
+ // upload to actually finish, not just for the next reconcile
250
+ // tick to fire. The decrement runs in `finally` so even a
251
+ // throwing upload doesn't strand the counter at non-zero.
252
+ this.inFlightUploads += 1;
253
+ fn()
254
+ .catch((error) => {
255
+ console.error(
256
+ `[dench-fs-daemon] sync failed for ${absPath}: ${
257
+ error instanceof Error ? error.message : String(error)
258
+ }`,
259
+ );
260
+ })
261
+ .finally(() => {
262
+ this.inFlightUploads = Math.max(0, this.inFlightUploads - 1);
263
+ });
247
264
  }, ms);
248
265
  this.timers.set(absPath, timer);
249
266
  }
267
+
268
+ /**
269
+ * Snapshot of pending sync work. Mirrored into the daemon status
270
+ * file once per reconcile tick so `dench-fs-daemon flush` can wait
271
+ * on it without an IPC channel.
272
+ */
273
+ drainStatus(): DrainStatus {
274
+ return {
275
+ pendingPaths: this.timers.size,
276
+ inFlightUploads: this.inFlightUploads,
277
+ };
278
+ }
250
279
  }
251
280
 
252
281
  // Mount detection helpers live in fs-daemon-mount.ts so they can be
@@ -284,6 +313,9 @@ const api = {
284
313
  getFileSignedDownloadUrl: makeFunctionReference<"action">(
285
314
  "functions/files:getFileSignedDownloadUrl",
286
315
  ),
316
+ reportDaemonHealth: makeFunctionReference<"mutation">(
317
+ "functions/files:reportDaemonHealth",
318
+ ),
287
319
  },
288
320
  };
289
321
 
@@ -371,9 +403,9 @@ class DenchFileClient {
371
403
  * fs status` to recursively crawl the canonical tree and diff
372
404
  * against the local FS snapshot.
373
405
  */
374
- async listTree(prefix: string): Promise<
375
- Array<{ path: string; contentHash?: string; isDir: boolean }>
376
- > {
406
+ async listTree(
407
+ prefix: string,
408
+ ): Promise<Array<{ path: string; contentHash?: string; isDir: boolean }>> {
377
409
  const rows = (await this.http.query(api.files.listTree, {
378
410
  prefix,
379
411
  apiKey: this.apiKey,
@@ -407,6 +439,27 @@ class DenchFileClient {
407
439
  });
408
440
  }
409
441
 
442
+ /**
443
+ * Heartbeat the per-org `fileTreeHealth` row. Best-effort — a Convex
444
+ * blip should never block the reconcile path.
445
+ */
446
+ async reportHealth(args: {
447
+ pid: number;
448
+ fsType: string;
449
+ isFuse: boolean;
450
+ workspace: string;
451
+ lastReconcileFinishedAt: number;
452
+ lastFlushAt?: number;
453
+ onDiskCount: number;
454
+ activeBreakers: number;
455
+ pendingDrain: number;
456
+ }): Promise<void> {
457
+ await this.http.mutation(api.files.reportDaemonHealth, {
458
+ ...args,
459
+ apiKey: this.apiKey,
460
+ } as never);
461
+ }
462
+
410
463
  /**
411
464
  * Subscribe to fileTree updates for the org and invoke onChange whenever
412
465
  * a row's contentHash diverges from our local lastSyncedHash. The
@@ -505,6 +558,10 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
505
558
  fsType: mount.fsType,
506
559
  isFuse: mount.isFuse,
507
560
  });
561
+ // Hook the writer to the live tracker so every status flush
562
+ // captures the current pending/in-flight counts. The flush
563
+ // subcommand polls these via the status file.
564
+ status.setDrainSnapshot(() => tracker.drainStatus());
508
565
  tracker.setBreakerListener((breakerState) => {
509
566
  status.recordBreakerEvent(breakerState);
510
567
  void status.flush();
@@ -580,9 +637,15 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
580
637
  );
581
638
  const uploadUrl = await client.generateUploadUrl(posixPath);
582
639
  const storageId = await client.putBytes(uploadUrl, bytes);
640
+ // Only inline text for SMALL files that look like real UTF-8.
641
+ // Without the binary check, a 50KB PNG would get
642
+ // `TextDecoder({ fatal: false }).decode(bytes)`'d into a stream
643
+ // of U+FFFD replacement chars and persisted into
644
+ // `fileContents.text`, corrupting every viewer that hits the
645
+ // inline cache instead of the storage blob. See cli/fs-daemon-binary.ts.
583
646
  const inlineText =
584
647
  stats.size <= MAX_INLINE_TEXT_BYTES
585
- ? new TextDecoder("utf-8", { fatal: false }).decode(bytes)
648
+ ? decodeInlineTextOrUndefined(bytes)
586
649
  : undefined;
587
650
  const result = await client.commitFileUpload({
588
651
  path: posixPath,
@@ -645,8 +708,10 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
645
708
  // (any file write on a Mountpoint volume from a process other than
646
709
  // this sandbox, or from the Daytona SDK uploadFile path) and writes
647
710
  // 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({
711
+ // the daemon is alive and what it last did. Also mirrors the
712
+ // heartbeat to Convex `fileTreeHealth` so the UI sidebar + chat-
713
+ // turn end-of-turn check can surface drift loudly.
714
+ const reconciler = startReconcileLoop({
650
715
  workspace: daemonArgs.workspace,
651
716
  intervalMs: reconcileIntervalMs,
652
717
  tracker,
@@ -669,10 +734,50 @@ async function runDaemon(daemonArgs: Args): Promise<void> {
669
734
  );
670
735
  }
671
736
  },
737
+ reportHealth: async (snapshot) => {
738
+ await client.reportHealth({
739
+ pid: process.pid,
740
+ fsType: mount.fsType,
741
+ isFuse: mount.isFuse,
742
+ workspace: daemonArgs.workspace,
743
+ lastReconcileFinishedAt: snapshot.finishedAt,
744
+ lastFlushAt: status.snapshot().lastFlushAt ?? undefined,
745
+ onDiskCount: snapshot.observed,
746
+ activeBreakers: snapshot.activeBreakers,
747
+ pendingDrain: snapshot.pendingDrain,
748
+ });
749
+ },
750
+ });
751
+
752
+ // SIGUSR1 = "flush requested" from `dench-fs-daemon flush` (chat-turn
753
+ // calls it after every execute_bash that touched /workspace). Run an
754
+ // immediate reconcile tick + record the flush completion timestamp so
755
+ // the polling caller can confirm the signal was honored. Errors are
756
+ // swallowed loudly into the daemon log; the flush command itself is
757
+ // responsible for surfacing timeouts.
758
+ process.on("SIGUSR1", () => {
759
+ const requestedAt = Date.now();
760
+ console.log(
761
+ `[dench-fs-daemon] SIGUSR1 received — running reconcile tick on demand`,
762
+ );
763
+ void reconciler
764
+ .runOneTick()
765
+ .catch((error) =>
766
+ console.error(
767
+ `[dench-fs-daemon] flush tick failed: ${
768
+ error instanceof Error ? error.message : String(error)
769
+ }`,
770
+ ),
771
+ )
772
+ .then(() => {
773
+ status.recordFlushCompleted(requestedAt);
774
+ return status.flush();
775
+ })
776
+ .catch(() => undefined);
672
777
  });
673
778
 
674
779
  process.on("SIGTERM", () => {
675
- stopReconcile();
780
+ reconciler.stop();
676
781
  unsubscribe();
677
782
  watcher.close().catch(() => undefined);
678
783
  process.exit(0);
@@ -742,6 +847,18 @@ async function snapshotWorkspace(
742
847
  return result;
743
848
  }
744
849
 
850
+ type ReconcileLoopHandle = {
851
+ /** Tear down the periodic timers (called on SIGTERM). */
852
+ stop: () => void;
853
+ /**
854
+ * Run the reconcile body once on demand. Returned so the SIGUSR1
855
+ * handler can drain disk-only writes immediately without waiting
856
+ * for the next 5–30s tick — that's how `dench-fs-daemon flush`
857
+ * achieves its sub-second turnaround.
858
+ */
859
+ runOneTick: () => Promise<void>;
860
+ };
861
+
745
862
  function startReconcileLoop(args: {
746
863
  workspace: string;
747
864
  intervalMs: number;
@@ -749,68 +866,129 @@ function startReconcileLoop(args: {
749
866
  status: DaemonStatusWriter;
750
867
  onLocalChange: (absPath: string) => void;
751
868
  onLocalDelete: (absPath: string) => Promise<void> | void;
752
- }): () => void {
869
+ /**
870
+ * Called once per tick (after the status file is flushed) to mirror
871
+ * the daemon health into Convex `fileTreeHealth`. Best-effort —
872
+ * Convex blips never block the reconcile path, but persistent
873
+ * failures get logged.
874
+ */
875
+ reportHealth?: (snapshot: {
876
+ finishedAt: number;
877
+ observed: number;
878
+ activeBreakers: number;
879
+ pendingDrain: number;
880
+ }) => Promise<void> | void;
881
+ }): ReconcileLoopHandle {
753
882
  const last = new Map<string, FileSnapshot>();
754
883
 
755
884
  let stopped = false;
885
+ // Serialize concurrent ticks (SIGUSR1 + scheduled timer can race).
886
+ // The flush-driven tick can arrive milliseconds before the scheduled
887
+ // one; without serialization we'd double-walk the volume and the
888
+ // status file's `lastReconcile.finishedAt` would jitter
889
+ // backwards if writes interleave.
890
+ let inFlightTick: Promise<void> | null = null;
756
891
  const tick = async () => {
757
892
  if (stopped) return;
893
+ if (inFlightTick) {
894
+ await inFlightTick;
895
+ return;
896
+ }
758
897
  const start = Date.now();
759
898
  let observed = 0;
760
899
  let scheduled = 0;
761
900
  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++;
901
+ inFlightTick = (async () => {
902
+ try {
903
+ const snapshot = await snapshotWorkspace(args.workspace);
904
+ observed = snapshot.size;
905
+ for (const [absPath, info] of snapshot) {
906
+ const prev = last.get(absPath);
907
+ if (
908
+ !prev ||
909
+ prev.size !== info.size ||
910
+ prev.mtimeMs !== info.mtimeMs
911
+ ) {
912
+ args.onLocalChange(absPath);
913
+ scheduled++;
914
+ }
770
915
  }
771
- }
772
- for (const absPath of last.keys()) {
773
- if (!snapshot.has(absPath)) {
774
- await args.onLocalDelete(absPath);
775
- deleted++;
916
+ for (const absPath of last.keys()) {
917
+ if (!snapshot.has(absPath)) {
918
+ await args.onLocalDelete(absPath);
919
+ deleted++;
920
+ }
921
+ }
922
+ last.clear();
923
+ for (const [absPath, info] of snapshot) {
924
+ last.set(absPath, info);
925
+ }
926
+ } catch (error) {
927
+ console.error(
928
+ `[dench-fs-daemon] reconcile scan failed: ${
929
+ error instanceof Error ? error.message : String(error)
930
+ }`,
931
+ );
932
+ } finally {
933
+ const finishedAt = Date.now();
934
+ args.status.recordReconcile({
935
+ finishedAt,
936
+ durationMs: finishedAt - start,
937
+ observed,
938
+ scheduledForSync: scheduled,
939
+ deleted,
940
+ breakers: args.tracker.activeBreakers(),
941
+ });
942
+ await args.status.flush();
943
+ if (args.reportHealth) {
944
+ // Best-effort heartbeat into Convex `fileTreeHealth`. The
945
+ // chat-turn end-of-turn check + sidebar pill key off this
946
+ // row to surface "sync degraded" without polling the
947
+ // sandbox FS directly.
948
+ const drain = args.tracker.drainStatus();
949
+ try {
950
+ await args.reportHealth({
951
+ finishedAt,
952
+ observed,
953
+ activeBreakers: args.tracker.activeBreakers().length,
954
+ pendingDrain: drain.pendingPaths + drain.inFlightUploads,
955
+ });
956
+ } catch (error) {
957
+ console.error(
958
+ `[dench-fs-daemon] reportHealth failed: ${
959
+ error instanceof Error ? error.message : String(error)
960
+ }`,
961
+ );
962
+ }
776
963
  }
777
964
  }
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
- );
965
+ })();
966
+ try {
967
+ await inFlightTick;
788
968
  } 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();
969
+ inFlightTick = null;
798
970
  }
799
971
  };
800
972
 
801
973
  // Stagger the first tick so we don't race chokidar's initial add
802
974
  // floods on daemon startup.
803
- const initial = setTimeout(() => {
804
- void tick();
805
- }, Math.min(args.intervalMs, 5_000));
975
+ const initial = setTimeout(
976
+ () => {
977
+ void tick();
978
+ },
979
+ Math.min(args.intervalMs, 5_000),
980
+ );
806
981
  const interval = setInterval(() => {
807
982
  void tick();
808
983
  }, args.intervalMs);
809
984
 
810
- return () => {
811
- stopped = true;
812
- clearTimeout(initial);
813
- clearInterval(interval);
985
+ return {
986
+ stop: () => {
987
+ stopped = true;
988
+ clearTimeout(initial);
989
+ clearInterval(interval);
990
+ },
991
+ runOneTick: tick,
814
992
  };
815
993
  }
816
994
 
@@ -836,6 +1014,20 @@ type DaemonStatus = {
836
1014
  scheduledForSync: number;
837
1015
  deleted: number;
838
1016
  } | null;
1017
+ /**
1018
+ * Wall-clock of the most recent SIGUSR1-driven flush request that
1019
+ * the daemon completed. Used by `dench-fs-daemon flush` to confirm
1020
+ * the daemon actually picked up the signal (not just that the
1021
+ * scheduled reconcile tick happened to land afterwards).
1022
+ */
1023
+ lastFlushAt: number | null;
1024
+ /**
1025
+ * Mirror of `HashTracker.drainStatus()` taken at the same moment as
1026
+ * `lastReconcile.finishedAt`. The flush poller waits for both
1027
+ * counts to be zero AND `lastReconcile.finishedAt > t0` before
1028
+ * declaring success.
1029
+ */
1030
+ pendingDrain: DrainStatus;
839
1031
  activeBreakers: BreakerState[];
840
1032
  recentBreakerEvents: BreakerState[];
841
1033
  };
@@ -843,6 +1035,9 @@ type DaemonStatus = {
843
1035
  class DaemonStatusWriter {
844
1036
  private state: DaemonStatus;
845
1037
  private writing: Promise<void> | null = null;
1038
+ // Pulled at flush() time so the on-disk drain count tracks the live
1039
+ // tracker without forcing every recordX() caller to plumb it through.
1040
+ private drainSnapshotFn: (() => DrainStatus) | null = null;
846
1041
 
847
1042
  constructor(args: {
848
1043
  workspace: string;
@@ -860,11 +1055,23 @@ class DaemonStatusWriter {
860
1055
  fsType: args.fsType,
861
1056
  isFuse: args.isFuse,
862
1057
  lastReconcile: null,
1058
+ lastFlushAt: null,
1059
+ pendingDrain: { pendingPaths: 0, inFlightUploads: 0 },
863
1060
  activeBreakers: [],
864
1061
  recentBreakerEvents: [],
865
1062
  };
866
1063
  }
867
1064
 
1065
+ /**
1066
+ * Wire up a callback the writer invokes inside `flush()` to grab
1067
+ * the latest drain numbers off the live HashTracker. Kept as a
1068
+ * snapshot fn (not a stored counter) so recordReconcile/breaker
1069
+ * events don't have to thread the tracker through.
1070
+ */
1071
+ setDrainSnapshot(fn: () => DrainStatus): void {
1072
+ this.drainSnapshotFn = fn;
1073
+ }
1074
+
868
1075
  recordReconcile(args: {
869
1076
  finishedAt: number;
870
1077
  durationMs: number;
@@ -883,6 +1090,16 @@ class DaemonStatusWriter {
883
1090
  this.state.activeBreakers = args.breakers;
884
1091
  }
885
1092
 
1093
+ /**
1094
+ * Mark a SIGUSR1 flush request as completed. Independent of
1095
+ * recordReconcile because the same tick may be triggered by either
1096
+ * the timer OR a flush signal — `lastFlushAt` proves the signal
1097
+ * specifically was honored.
1098
+ */
1099
+ recordFlushCompleted(at: number): void {
1100
+ this.state.lastFlushAt = at;
1101
+ }
1102
+
886
1103
  recordBreakerEvent(state: BreakerState): void {
887
1104
  const recent = [...this.state.recentBreakerEvents, state];
888
1105
  // Cap at last 10 events so the file stays small.
@@ -892,6 +1109,9 @@ class DaemonStatusWriter {
892
1109
 
893
1110
  async flush(): Promise<void> {
894
1111
  this.state.lastUpdatedAt = Date.now();
1112
+ if (this.drainSnapshotFn) {
1113
+ this.state.pendingDrain = this.drainSnapshotFn();
1114
+ }
895
1115
  const body = `${JSON.stringify(this.state, null, 2)}\n`;
896
1116
  // Serialize so concurrent flushes don't interleave writes.
897
1117
  const previous = this.writing ?? Promise.resolve();
@@ -904,6 +1124,15 @@ class DaemonStatusWriter {
904
1124
  );
905
1125
  await this.writing;
906
1126
  }
1127
+
1128
+ /**
1129
+ * Snapshot the current status state. Read-only — used by the
1130
+ * reconcile loop's per-tick health heartbeat to Convex without
1131
+ * forking a stale copy back into the writer.
1132
+ */
1133
+ snapshot(): Readonly<DaemonStatus> {
1134
+ return this.state;
1135
+ }
907
1136
  }
908
1137
 
909
1138
  // ── Live streaming subcommands ─────────────────────────────────────────────
@@ -992,6 +1221,79 @@ async function runStreamClose(args: string[]): Promise<void> {
992
1221
  await writeTokenStore(store);
993
1222
  }
994
1223
 
1224
+ // ── Flush subcommand ──────────────────────────────────────────────────────
1225
+ //
1226
+ // `dench-fs-daemon flush [--timeout-ms 5000]` blocks until the live
1227
+ // daemon has finished a reconcile tick AND drained every pending sync
1228
+ // timer + in-flight upload. Used by chat-turn after each `execute_bash`
1229
+ // that touched /workspace so the model sees Convex tree state that
1230
+ // matches what the bash command produced — no more 5s polling jitter
1231
+ // where files appear in the sidebar after the chat turn already
1232
+ // finished.
1233
+ //
1234
+ // Protocol:
1235
+ // 1. Read pid from /tmp/dench-fs-daemon.status.json.
1236
+ // 2. Send SIGUSR1 (the daemon's runDaemon registers a handler).
1237
+ // 3. Poll the status file every POLL_INTERVAL_MS until both
1238
+ // a) `lastReconcile.finishedAt > t0`, AND
1239
+ // b) `pendingDrain.pendingPaths === 0 && pendingDrain.inFlightUploads === 0`.
1240
+ // 4. Time out cleanly with exit 2 + the last-seen drain count so
1241
+ // callers (chat-turn) can decide whether to surface drift.
1242
+ //
1243
+ // Exit codes: 0 ok, 1 no daemon, 2 timeout, 3 signal failed.
1244
+ //
1245
+ // The polling logic itself lives in `cli/fs-daemon-flush.ts` so the
1246
+ // unit tests can exercise it without booting the daemon.
1247
+
1248
+ async function runFlush(argv: string[]): Promise<void> {
1249
+ const timeoutIndex = argv.indexOf("--timeout-ms");
1250
+ const parsedTimeout =
1251
+ timeoutIndex === -1
1252
+ ? FLUSH_DEFAULT_TIMEOUT_MS
1253
+ : Number.parseInt(
1254
+ argv[timeoutIndex + 1] ?? `${FLUSH_DEFAULT_TIMEOUT_MS}`,
1255
+ 10,
1256
+ );
1257
+ const timeoutMs = Number.isFinite(parsedTimeout)
1258
+ ? parsedTimeout
1259
+ : FLUSH_DEFAULT_TIMEOUT_MS;
1260
+ const json = argv.includes("--json");
1261
+
1262
+ const outcome = await runFlushPolling({
1263
+ timeoutMs,
1264
+ readStatus: async () => (await readStatusFile()).state,
1265
+ isPidAlive,
1266
+ signal: (pid) => {
1267
+ // process.kill with signal 'SIGUSR1' on Linux. On Darwin/local-dev
1268
+ // the same call works for any node process; Windows lacks SIGUSR1
1269
+ // but the daemon never runs there.
1270
+ process.kill(pid, "SIGUSR1");
1271
+ },
1272
+ });
1273
+
1274
+ if (json) {
1275
+ process.stdout.write(`${JSON.stringify(outcome)}\n`);
1276
+ } else if (outcome.ok) {
1277
+ process.stdout.write(
1278
+ `DENCH_FS_FLUSH=ok pid=${outcome.pid} duration=${outcome.durationMs}ms\n`,
1279
+ );
1280
+ } else if (outcome.reason === "timeout") {
1281
+ process.stderr.write(
1282
+ `DENCH_FS_FLUSH=timeout pid=${outcome.pid} duration=${outcome.durationMs}ms pending=${outcome.drain.pendingPaths} inflight=${outcome.drain.inFlightUploads}\n`,
1283
+ );
1284
+ } else if (outcome.reason === "signal_failed") {
1285
+ process.stderr.write(
1286
+ `DENCH_FS_FLUSH=signal_failed pid=${outcome.pid} error=${outcome.error}\n`,
1287
+ );
1288
+ } else {
1289
+ process.stderr.write(`DENCH_FS_FLUSH=no_daemon\n`);
1290
+ }
1291
+
1292
+ if (outcome.ok) return;
1293
+ process.exitCode =
1294
+ outcome.reason === "no_daemon" ? 1 : outcome.reason === "timeout" ? 2 : 3;
1295
+ }
1296
+
995
1297
  async function main(): Promise<void> {
996
1298
  const argv = process.argv.slice(2);
997
1299
  const subcommand = argv[0];
@@ -1012,6 +1314,10 @@ async function main(): Promise<void> {
1012
1314
  await runInitialSync(argv.slice(1));
1013
1315
  return;
1014
1316
  }
1317
+ if (subcommand === "flush") {
1318
+ await runFlush(argv.slice(1));
1319
+ return;
1320
+ }
1015
1321
  if (subcommand === "status") {
1016
1322
  await runStatus(argv.slice(1));
1017
1323
  return;
@@ -1071,7 +1377,7 @@ async function runStatus(argv: string[]): Promise<void> {
1071
1377
  const workspaceIndex = argv.indexOf("--workspace");
1072
1378
  const workspace =
1073
1379
  workspaceIndex === -1
1074
- ? (process.env.DENCH_VOLUME_PATH?.trim() || "/workspace")
1380
+ ? process.env.DENCH_VOLUME_PATH?.trim() || "/workspace"
1075
1381
  : (argv[workspaceIndex + 1] ?? "/workspace");
1076
1382
  const driftLimitIndex = argv.indexOf("--drift-limit");
1077
1383
  const driftLimit =
@@ -1080,7 +1386,11 @@ async function runStatus(argv: string[]): Promise<void> {
1080
1386
  : Math.max(0, Number.parseInt(argv[driftLimitIndex + 1] ?? "50", 10));
1081
1387
  const skipHash = argv.includes("--no-hash");
1082
1388
 
1083
- const report = await collectStatus({ workspace, driftLimit, hash: !skipHash });
1389
+ const report = await collectStatus({
1390
+ workspace,
1391
+ driftLimit,
1392
+ hash: !skipHash,
1393
+ });
1084
1394
  if (json) {
1085
1395
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1086
1396
  return;
@@ -1107,8 +1417,11 @@ async function collectStatus(args: {
1107
1417
  const pidAlive = statusFile.state ? isPidAlive(statusFile.state.pid) : false;
1108
1418
 
1109
1419
  let onDiskList: Array<{ path: string; size: number; mtimeMs: number }> = [];
1110
- let convexRows: Array<{ path: string; contentHash?: string; isDir: boolean }> =
1111
- [];
1420
+ let convexRows: Array<{
1421
+ path: string;
1422
+ contentHash?: string;
1423
+ isDir: boolean;
1424
+ }> = [];
1112
1425
  try {
1113
1426
  const snapshot = await snapshotWorkspace(args.workspace);
1114
1427
  for (const [absPath, info] of snapshot) {
@@ -1260,12 +1573,11 @@ async function readLogTail(lines: number): Promise<string[]> {
1260
1573
  }
1261
1574
  }
1262
1575
 
1263
- async function collectConvexTree(client: DenchFileClient): Promise<
1264
- Array<{ path: string; contentHash?: string; isDir: boolean }>
1265
- > {
1576
+ async function collectConvexTree(
1577
+ client: DenchFileClient,
1578
+ ): Promise<Array<{ path: string; contentHash?: string; isDir: boolean }>> {
1266
1579
  // listTree returns one directory's children; recurse into subdirs.
1267
- const out: Array<{ path: string; contentHash?: string; isDir: boolean }> =
1268
- [];
1580
+ const out: Array<{ path: string; contentHash?: string; isDir: boolean }> = [];
1269
1581
  const queue: string[] = ["/"];
1270
1582
  const seen = new Set<string>();
1271
1583
  while (queue.length > 0) {
@@ -1432,9 +1744,12 @@ async function runInitialSync(argv: string[]): Promise<void> {
1432
1744
  storageId,
1433
1745
  contentHash: hash,
1434
1746
  size: bytesBuf.byteLength,
1747
+ // Same binary-aware inlining as scheduleSync — a fresh-volume
1748
+ // initial sync that includes screenshots / PDFs / .zip would
1749
+ // otherwise plant U+FFFD-corrupted text rows for every blob.
1435
1750
  text:
1436
1751
  bytesBuf.byteLength <= MAX_INLINE_TEXT_BYTES
1437
- ? new TextDecoder("utf-8", { fatal: false }).decode(bytesBuf)
1752
+ ? decodeInlineTextOrUndefined(bytesBuf)
1438
1753
  : undefined,
1439
1754
  lastModifiedBy: "daemon",
1440
1755
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.4.1",
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",