@dench.com/cli 0.4.1 → 0.4.2
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 +10 -4
- package/crm.ts +278 -7
- package/dench.ts +3 -1
- package/fs-daemon.ts +386 -71
- package/package.json +1 -1
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
|
|
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):
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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(
|
|
375
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
?
|
|
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({
|
|
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<{
|
|
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(
|
|
1264
|
-
|
|
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
|
-
?
|
|
1752
|
+
? decodeInlineTextOrUndefined(bytesBuf)
|
|
1438
1753
|
: undefined,
|
|
1439
1754
|
lastModifiedBy: "daemon",
|
|
1440
1755
|
});
|