@indigoai-us/hq-cloud 6.11.11 → 6.11.12
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/dist/bin/sync-runner.d.ts +2 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +231 -52
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +265 -11
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/rescue-classify-ordering.test.js +58 -0
- package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
- package/dist/cli/rescue-core.js +138 -15
- package/dist/cli/rescue-core.js.map +1 -1
- package/dist/cli/share.d.ts +2 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +100 -32
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +30 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +178 -58
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +362 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +55 -10
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.js +61 -0
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +93 -6
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.js +59 -0
- package/dist/journal.test.js.map +1 -1
- package/dist/machine-auth.test.js +60 -2
- package/dist/machine-auth.test.js.map +1 -1
- package/dist/object-io.d.ts +37 -1
- package/dist/object-io.d.ts.map +1 -1
- package/dist/object-io.js +148 -29
- package/dist/object-io.js.map +1 -1
- package/dist/object-io.test.js +121 -0
- package/dist/object-io.test.js.map +1 -1
- package/dist/operation-lock.d.ts +8 -8
- package/dist/operation-lock.d.ts.map +1 -1
- package/dist/operation-lock.js +99 -32
- package/dist/operation-lock.js.map +1 -1
- package/dist/operation-lock.test.js +51 -4
- package/dist/operation-lock.test.js.map +1 -1
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +8 -2
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +34 -0
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/prefix-coalesce.d.ts +20 -9
- package/dist/prefix-coalesce.d.ts.map +1 -1
- package/dist/prefix-coalesce.js +124 -28
- package/dist/prefix-coalesce.js.map +1 -1
- package/dist/prefix-coalesce.test.js +57 -2
- package/dist/prefix-coalesce.test.js.map +1 -1
- package/dist/remote-pull.d.ts +6 -1
- package/dist/remote-pull.d.ts.map +1 -1
- package/dist/remote-pull.js +62 -13
- package/dist/remote-pull.js.map +1 -1
- package/dist/remote-pull.test.js +189 -0
- package/dist/remote-pull.test.js.map +1 -1
- package/dist/s3.d.ts +2 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +197 -116
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +109 -0
- package/dist/s3.test.js.map +1 -1
- package/dist/scope-shrink.d.ts +3 -2
- package/dist/scope-shrink.d.ts.map +1 -1
- package/dist/scope-shrink.js +1 -1
- package/dist/scope-shrink.js.map +1 -1
- package/dist/skill-telemetry.d.ts +1 -1
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +69 -9
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +86 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/event-sync.d.ts +6 -0
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +34 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +73 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/metrics.d.ts +17 -1
- package/dist/sync/metrics.d.ts.map +1 -1
- package/dist/sync/metrics.js +32 -1
- package/dist/sync/metrics.js.map +1 -1
- package/dist/sync/metrics.test.js +74 -1
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/pull-scope.d.ts.map +1 -1
- package/dist/sync/pull-scope.js +15 -7
- package/dist/sync/pull-scope.js.map +1 -1
- package/dist/sync/push-receiver.d.ts +6 -5
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +13 -15
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +36 -1
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/telemetry.d.ts +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +59 -6
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +74 -0
- package/dist/telemetry.test.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.d.ts +36 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +152 -30
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +103 -0
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +298 -11
- package/src/bin/sync-runner.ts +254 -52
- package/src/cli/rescue-classify-ordering.test.ts +61 -0
- package/src/cli/rescue-core.ts +174 -15
- package/src/cli/share.test.ts +38 -0
- package/src/cli/share.ts +103 -34
- package/src/cli/sync.test.ts +435 -1
- package/src/cli/sync.ts +217 -64
- package/src/cognito-auth.test.ts +77 -0
- package/src/cognito-auth.ts +73 -11
- package/src/index.ts +8 -0
- package/src/journal.test.ts +72 -0
- package/src/journal.ts +95 -8
- package/src/machine-auth.test.ts +64 -2
- package/src/object-io.test.ts +142 -0
- package/src/object-io.ts +182 -30
- package/src/operation-lock.test.ts +63 -4
- package/src/operation-lock.ts +99 -31
- package/src/personal-vault.test.ts +42 -0
- package/src/personal-vault.ts +8 -2
- package/src/prefix-coalesce.test.ts +71 -1
- package/src/prefix-coalesce.ts +155 -30
- package/src/remote-pull.test.ts +205 -0
- package/src/remote-pull.ts +77 -14
- package/src/s3.test.ts +126 -0
- package/src/s3.ts +237 -122
- package/src/scope-shrink.ts +6 -3
- package/src/skill-telemetry.test.ts +109 -0
- package/src/skill-telemetry.ts +82 -14
- package/src/sync/event-sync.test.ts +75 -0
- package/src/sync/event-sync.ts +54 -1
- package/src/sync/metrics.test.ts +81 -0
- package/src/sync/metrics.ts +59 -4
- package/src/sync/pull-scope.ts +23 -7
- package/src/sync/push-receiver.test.ts +38 -1
- package/src/sync/push-receiver.ts +15 -18
- package/src/telemetry.test.ts +85 -0
- package/src/telemetry.ts +69 -6
- package/src/types.ts +8 -0
- package/src/watcher.test.ts +117 -0
- package/src/watcher.ts +209 -33
|
@@ -191,8 +191,8 @@ export interface PushReceiverContext {
|
|
|
191
191
|
* company/path; in tests it's a fake recording invocations.
|
|
192
192
|
*
|
|
193
193
|
* Errors from `syncFn` are CAUGHT by the receiver — they log and the loop
|
|
194
|
-
* continues. A
|
|
195
|
-
*
|
|
194
|
+
* continues. A failed sync is left unacknowledged in SQS so the queue can
|
|
195
|
+
* redeliver it after the visibility timeout.
|
|
196
196
|
*/
|
|
197
197
|
export type SyncEngineFn = (ctx: PushReceiverContext) => Promise<void>;
|
|
198
198
|
|
|
@@ -613,18 +613,18 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
613
613
|
|
|
614
614
|
const handled = await this.dispatch(validated);
|
|
615
615
|
if (handled) {
|
|
616
|
-
// Delete only after
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
// dedupe; the poll/cadence safety net is the recovery path for the throw.
|
|
616
|
+
// Delete only after a successful handoff or dedupe-skip. A syncFn throw
|
|
617
|
+
// leaves the message undeleted so SQS redelivery can retry the targeted
|
|
618
|
+
// pull.
|
|
620
619
|
await this.safeDelete(msg);
|
|
621
620
|
}
|
|
622
621
|
}
|
|
623
622
|
|
|
624
623
|
/**
|
|
625
|
-
* Dedupe + invoke `syncFn`. Returns true once the event has been
|
|
626
|
-
* for (deduped, or syncFn
|
|
627
|
-
* Stores the in-flight promise so `dispose()` can drain
|
|
624
|
+
* Dedupe + invoke `syncFn`. Returns true only once the event has been
|
|
625
|
+
* accounted for successfully (deduped, or syncFn completed) so the caller can
|
|
626
|
+
* delete the message. Stores the in-flight promise so `dispose()` can drain
|
|
627
|
+
* it.
|
|
628
628
|
*/
|
|
629
629
|
private async dispatch(event: PushEvent): Promise<boolean> {
|
|
630
630
|
if (this.disposing || this.disposed) return false;
|
|
@@ -645,18 +645,13 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
645
645
|
return true;
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
-
// Record BEFORE invoking syncFn — a back-to-back event for the same path
|
|
649
|
-
// must see this sequence in its dedupe check. The trade-off: a syncFn that
|
|
650
|
-
// throws still advances the counter ("latest we KNOW about"); the safety
|
|
651
|
-
// net poll is the recovery path for the throw.
|
|
652
|
-
this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
|
|
653
|
-
|
|
654
648
|
const controller = new AbortController();
|
|
655
649
|
this.inFlightAbort = controller;
|
|
656
650
|
const ctx: PushReceiverContext = { event, signal: controller.signal };
|
|
657
651
|
|
|
658
652
|
const holder: { p: Promise<void> | null } = { p: null };
|
|
659
653
|
const startMs = this.now();
|
|
654
|
+
let handled = false;
|
|
660
655
|
const p: Promise<void> = (async () => {
|
|
661
656
|
try {
|
|
662
657
|
this.logger.debug(
|
|
@@ -669,7 +664,9 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
669
664
|
"push receiver invoking sync engine",
|
|
670
665
|
);
|
|
671
666
|
await this.syncFn(ctx);
|
|
667
|
+
this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
|
|
672
668
|
this._processedCount += 1;
|
|
669
|
+
handled = true;
|
|
673
670
|
this.logger.debug(
|
|
674
671
|
{
|
|
675
672
|
event: "receiver.sync.completed",
|
|
@@ -715,8 +712,8 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
715
712
|
});
|
|
716
713
|
} catch (err) {
|
|
717
714
|
// Critical: catch, log, return. A misbehaving sync engine must never
|
|
718
|
-
// crash the receiver loop.
|
|
719
|
-
//
|
|
715
|
+
// crash the receiver loop. Leaving the SQS message undeleted lets
|
|
716
|
+
// redelivery retry the failed targeted pull.
|
|
720
717
|
const e = err as NodeJS.ErrnoException;
|
|
721
718
|
this.logger.error(
|
|
722
719
|
{
|
|
@@ -736,7 +733,7 @@ export class SqsPushReceiver implements PushReceiver {
|
|
|
736
733
|
holder.p = p;
|
|
737
734
|
this.inFlightSync = p;
|
|
738
735
|
await p;
|
|
739
|
-
return
|
|
736
|
+
return handled;
|
|
740
737
|
}
|
|
741
738
|
|
|
742
739
|
/** Delete a message, swallowing transport errors (redelivery is harmless). */
|
package/src/telemetry.test.ts
CHANGED
|
@@ -292,6 +292,91 @@ describe("collectAndSendTelemetry", () => {
|
|
|
292
292
|
expect(lastWire).toBeLessThan(1_000_000);
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
it("F19: caps usage telemetry batches at 100 events", async () => {
|
|
296
|
+
const client = makeClient();
|
|
297
|
+
const lines = Array.from({ length: 101 }, (_, i) => JSON.stringify({
|
|
298
|
+
type: "user",
|
|
299
|
+
timestamp: `2026-04-25T10:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}Z`,
|
|
300
|
+
sessionId: "s-f19-row-cap",
|
|
301
|
+
uuid: `u-f19-row-${i}`,
|
|
302
|
+
cwd: "/Users/x",
|
|
303
|
+
gitBranch: "main",
|
|
304
|
+
userType: "human",
|
|
305
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }], id: `m${i}` },
|
|
306
|
+
}));
|
|
307
|
+
writeJsonl(env, "proj", "f19-row-cap.jsonl", lines);
|
|
308
|
+
|
|
309
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
310
|
+
|
|
311
|
+
expect(result.eventsSent).toBe(101);
|
|
312
|
+
expect(result.batchesSent).toBe(2);
|
|
313
|
+
expect(client.posts.map((post) => post.events.length)).toEqual([100, 1]);
|
|
314
|
+
for (const post of client.posts) {
|
|
315
|
+
expect(post.events.length).toBeLessThanOrEqual(100);
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("F19: caps usage telemetry POST bodies at 240 KiB", async () => {
|
|
320
|
+
const client = makeClient();
|
|
321
|
+
const branch = "x".repeat(9_000);
|
|
322
|
+
const lines = Array.from({ length: 30 }, (_, i) => JSON.stringify({
|
|
323
|
+
type: "user",
|
|
324
|
+
timestamp: `2026-04-25T11:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}Z`,
|
|
325
|
+
sessionId: "s-f19-byte-cap",
|
|
326
|
+
uuid: `u-f19-byte-${i}`,
|
|
327
|
+
cwd: "/Users/x",
|
|
328
|
+
gitBranch: branch,
|
|
329
|
+
userType: "human",
|
|
330
|
+
message: { role: "user", content: [{ type: "text", text: "hi" }], id: `m${i}` },
|
|
331
|
+
}));
|
|
332
|
+
writeJsonl(env, "proj", "f19-byte-cap.jsonl", lines);
|
|
333
|
+
|
|
334
|
+
const result = await collectAndSendTelemetry(makeOpts(env, client));
|
|
335
|
+
const wireSizes = client.posts.map((post) => Buffer.byteLength(JSON.stringify(post), "utf-8"));
|
|
336
|
+
|
|
337
|
+
expect(result.eventsSent).toBe(30);
|
|
338
|
+
expect(result.batchesSent).toBeGreaterThan(1);
|
|
339
|
+
for (const wireSize of wireSizes) {
|
|
340
|
+
expect(wireSize).toBeLessThanOrEqual(240 * 1024);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("R-F19: bounds a single oversized sanitized usage row before POST", async () => {
|
|
345
|
+
const client = makeClient();
|
|
346
|
+
const logs: string[] = [];
|
|
347
|
+
const hugeBranch = "x".repeat(400_000);
|
|
348
|
+
writeJsonl(env, "proj", "r-f19-singleton.jsonl", [
|
|
349
|
+
JSON.stringify({
|
|
350
|
+
type: "user",
|
|
351
|
+
timestamp: "2026-06-19T10:00:00.000Z",
|
|
352
|
+
sessionId: "s-r-f19-singleton",
|
|
353
|
+
uuid: "u-r-f19-singleton",
|
|
354
|
+
cwd: "/Users/x",
|
|
355
|
+
gitBranch: hugeBranch,
|
|
356
|
+
userType: "human",
|
|
357
|
+
message: {
|
|
358
|
+
role: "user",
|
|
359
|
+
content: [{ type: "text", text: "hi" }],
|
|
360
|
+
id: "m-r-f19-singleton",
|
|
361
|
+
},
|
|
362
|
+
}),
|
|
363
|
+
]);
|
|
364
|
+
|
|
365
|
+
const result = await collectAndSendTelemetry({
|
|
366
|
+
...makeOpts(env, client),
|
|
367
|
+
log: (msg) => logs.push(msg),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
expect(result.eventsSent).toBe(1);
|
|
371
|
+
expect(client.posts).toHaveLength(1);
|
|
372
|
+
const wireSize = Buffer.byteLength(JSON.stringify(client.posts[0]), "utf-8");
|
|
373
|
+
expect(wireSize).toBeLessThanOrEqual(240 * 1024);
|
|
374
|
+
expect(client.posts[0].events[0].gitBranch).not.toBe(hugeBranch);
|
|
375
|
+
expect(logs.some((line) => line.includes("oversized row truncated"))).toBe(
|
|
376
|
+
true,
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
|
|
295
380
|
it("(e) POST 500 → cursor NOT advanced", async () => {
|
|
296
381
|
const client = makeClient({ postResponse: new Error("server 500") });
|
|
297
382
|
writeJsonl(env, "proj", "s.jsonl", [USER_ROW, ASST_ROW, USER_ROW]);
|
package/src/telemetry.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
|
|
12
12
|
* sanitizes new rows through a tight allowlist that matches the server's
|
|
13
13
|
* KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
|
|
14
|
-
* batches into
|
|
14
|
+
* batches into server-sized POST bodies, and ships them to `/v1/usage`.
|
|
15
15
|
*
|
|
16
16
|
* Trust model: the caller's `personUid` is resolved on the server from the
|
|
17
17
|
* Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
|
|
@@ -245,7 +245,9 @@ async function listJsonlFiles(root: string): Promise<string[]> {
|
|
|
245
245
|
|
|
246
246
|
// ── Batching primitives ───────────────────────────────────────────────────────
|
|
247
247
|
|
|
248
|
-
const
|
|
248
|
+
const MAX_BATCH_EVENTS = 100;
|
|
249
|
+
const MAX_BATCH_BYTES = 240 * 1024;
|
|
250
|
+
const ROW_TRUNCATION_SUFFIX = "...[truncated]";
|
|
249
251
|
|
|
250
252
|
interface RowSource {
|
|
251
253
|
filePath: string;
|
|
@@ -273,6 +275,49 @@ function envelopeBytes(machineId: string, installerVersion: string): number {
|
|
|
273
275
|
);
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
function jsonBytes(value: unknown): number {
|
|
279
|
+
return Buffer.byteLength(JSON.stringify(value), "utf-8");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function truncateLongestStringField(row: Record<string, unknown>): boolean {
|
|
283
|
+
let longestKey: string | undefined;
|
|
284
|
+
let longestBytes = 0;
|
|
285
|
+
for (const [key, value] of Object.entries(row)) {
|
|
286
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
287
|
+
const bytes = Buffer.byteLength(value, "utf-8");
|
|
288
|
+
if (bytes > longestBytes) {
|
|
289
|
+
longestBytes = bytes;
|
|
290
|
+
longestKey = key;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (longestKey === undefined) return false;
|
|
294
|
+
|
|
295
|
+
const value = row[longestKey] as string;
|
|
296
|
+
const keepChars =
|
|
297
|
+
value.length > ROW_TRUNCATION_SUFFIX.length
|
|
298
|
+
? Math.floor((value.length - ROW_TRUNCATION_SUFFIX.length) / 2)
|
|
299
|
+
: 0;
|
|
300
|
+
const next =
|
|
301
|
+
keepChars > 0
|
|
302
|
+
? `${value.slice(0, keepChars)}${ROW_TRUNCATION_SUFFIX}`
|
|
303
|
+
: "";
|
|
304
|
+
if (next === value) return false;
|
|
305
|
+
row[longestKey] = next;
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function boundRowForPost(
|
|
310
|
+
row: Record<string, unknown>,
|
|
311
|
+
maxRowBytes: number,
|
|
312
|
+
): Record<string, unknown> | null {
|
|
313
|
+
if (maxRowBytes < 0) return null;
|
|
314
|
+
const bounded = { ...row };
|
|
315
|
+
while (jsonBytes(bounded) > maxRowBytes) {
|
|
316
|
+
if (!truncateLongestStringField(bounded)) return null;
|
|
317
|
+
}
|
|
318
|
+
return bounded;
|
|
319
|
+
}
|
|
320
|
+
|
|
276
321
|
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
277
322
|
|
|
278
323
|
/**
|
|
@@ -324,7 +369,7 @@ export async function collectAndSendTelemetry(
|
|
|
324
369
|
|
|
325
370
|
const files = await listJsonlFiles(claudeProjectsRoot);
|
|
326
371
|
|
|
327
|
-
// 3. Walk each file, sanitize new rows, batch, flush at
|
|
372
|
+
// 3. Walk each file, sanitize new rows, batch, flush at the server contract.
|
|
328
373
|
//
|
|
329
374
|
// Byte accounting is incremental: we track `batchBytes` as the projected
|
|
330
375
|
// serialized size of the current batch (envelope + per-row JSON + commas).
|
|
@@ -440,15 +485,33 @@ export async function collectAndSendTelemetry(
|
|
|
440
485
|
);
|
|
441
486
|
const sanitized = sanitizeRow(parsed, companyUid);
|
|
442
487
|
if (!sanitized) continue;
|
|
488
|
+
const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
|
|
489
|
+
const wasOversized = jsonBytes(sanitized) > maxRowBytes;
|
|
490
|
+
const bounded = boundRowForPost(sanitized, maxRowBytes);
|
|
491
|
+
if (!bounded) {
|
|
492
|
+
log(
|
|
493
|
+
`[telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
|
|
494
|
+
);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (wasOversized) {
|
|
498
|
+
log(
|
|
499
|
+
`[telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
443
502
|
|
|
444
503
|
// Cost of appending this row to the current batch: the row's JSON
|
|
445
504
|
// length plus 1 byte for the leading comma when there's already at
|
|
446
505
|
// least one row. (No comma when the batch is empty — the row sits
|
|
447
506
|
// alone inside the events array.)
|
|
448
|
-
const rowJsonBytes = Buffer.byteLength(JSON.stringify(
|
|
507
|
+
const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
|
|
449
508
|
const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
|
|
450
509
|
|
|
451
|
-
if (
|
|
510
|
+
if (
|
|
511
|
+
batchEvents.length > 0 &&
|
|
512
|
+
(batchEvents.length >= MAX_BATCH_EVENTS ||
|
|
513
|
+
batchBytes + addCost > MAX_BATCH_BYTES)
|
|
514
|
+
) {
|
|
452
515
|
await flush();
|
|
453
516
|
// After flush, batchEvents is empty → no comma needed for the first row.
|
|
454
517
|
batchBytes = ENVELOPE_BYTES + rowJsonBytes;
|
|
@@ -456,7 +519,7 @@ export async function collectAndSendTelemetry(
|
|
|
456
519
|
batchBytes += addCost;
|
|
457
520
|
}
|
|
458
521
|
|
|
459
|
-
batchEvents.push(
|
|
522
|
+
batchEvents.push(bounded);
|
|
460
523
|
batchSources.push({
|
|
461
524
|
filePath,
|
|
462
525
|
endOffset: lineEndOffsets[i],
|
package/src/types.ts
CHANGED
|
@@ -72,6 +72,14 @@ export interface JournalEntry {
|
|
|
72
72
|
*/
|
|
73
73
|
removedAt?: string;
|
|
74
74
|
removedReason?: "scope_shrink" | "narrow_apply" | "manual";
|
|
75
|
+
/**
|
|
76
|
+
* Durable automatic-pull retention marker. Set when a scope shrink keeps an
|
|
77
|
+
* out-of-scope caller-authored or unknown-author entry on disk instead of
|
|
78
|
+
* pruning it. Subsequent pulls under the already-narrowed scope use this to
|
|
79
|
+
* exclude the survivor from remote-missing local deletes; it is cleared if
|
|
80
|
+
* the entry becomes in-scope again.
|
|
81
|
+
*/
|
|
82
|
+
outOfScopeProtected?: boolean;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
package/src/watcher.test.ts
CHANGED
|
@@ -49,6 +49,10 @@ function makeHarness(opts?: { debounceMs?: number }) {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
async function flushImmediate(): Promise<void> {
|
|
53
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
describe("FakeClock", () => {
|
|
53
57
|
it("fires a timer exactly when its deadline is reached", () => {
|
|
54
58
|
const clock = new FakeClock();
|
|
@@ -166,6 +170,34 @@ describe("US-001: WatchPushDriver — debounced push seam", () => {
|
|
|
166
170
|
driver.dispose();
|
|
167
171
|
});
|
|
168
172
|
|
|
173
|
+
it("F17: rejected watch push is caught instead of surfacing as an unhandled rejection", async () => {
|
|
174
|
+
const clock = new FakeClock();
|
|
175
|
+
const rejection = new Error("push failed");
|
|
176
|
+
const push = vi.fn(async () => {
|
|
177
|
+
throw rejection;
|
|
178
|
+
});
|
|
179
|
+
const driver = new WatchPushDriver({ debounceMs: DEBOUNCE, clock, push });
|
|
180
|
+
const unhandled: unknown[] = [];
|
|
181
|
+
const onUnhandled = (reason: unknown) => {
|
|
182
|
+
unhandled.push(reason);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
process.prependListener("unhandledRejection", onUnhandled);
|
|
186
|
+
try {
|
|
187
|
+
driver.notifyChange();
|
|
188
|
+
clock.advance(DEBOUNCE);
|
|
189
|
+
await Promise.resolve();
|
|
190
|
+
await flushImmediate();
|
|
191
|
+
|
|
192
|
+
expect(push).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(driver.isPushing()).toBe(false);
|
|
194
|
+
expect(unhandled).toEqual([]);
|
|
195
|
+
} finally {
|
|
196
|
+
process.removeListener("unhandledRejection", onUnhandled);
|
|
197
|
+
driver.dispose();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
169
201
|
it("respects a custom debounce window", async () => {
|
|
170
202
|
const h = makeHarness({ debounceMs: 500 });
|
|
171
203
|
h.emitChange();
|
|
@@ -374,6 +406,42 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
|
|
|
374
406
|
clock.advance(DEBOUNCE);
|
|
375
407
|
expect(changed).toHaveBeenCalledTimes(2);
|
|
376
408
|
});
|
|
409
|
+
|
|
410
|
+
it("R-F13: caps watcher backlog and keeps EMFILE polling fallback removed", () => {
|
|
411
|
+
const clock = new FakeClock();
|
|
412
|
+
const changed = vi.fn();
|
|
413
|
+
const overflow = vi.fn();
|
|
414
|
+
const watcher = new TreeWatcher({
|
|
415
|
+
hqRoot: ROOT,
|
|
416
|
+
debounceMs: DEBOUNCE,
|
|
417
|
+
clock,
|
|
418
|
+
pathFilter: () => true,
|
|
419
|
+
maxPendingPaths: 3,
|
|
420
|
+
maxPendingBytes: 10_000,
|
|
421
|
+
onBacklogOverflow: overflow,
|
|
422
|
+
});
|
|
423
|
+
watcher.onChange(changed);
|
|
424
|
+
|
|
425
|
+
for (let i = 0; i < 10; i++) {
|
|
426
|
+
watcher.handleEvent(path.join(ROOT, `bulk-${i}.md`));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
expect(overflow).toHaveBeenCalledTimes(1);
|
|
430
|
+
clock.advance(DEBOUNCE);
|
|
431
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
432
|
+
const batch = changed.mock.calls[0][1] as {
|
|
433
|
+
paths: Map<string, string>;
|
|
434
|
+
overflowed?: boolean;
|
|
435
|
+
droppedPaths?: number;
|
|
436
|
+
};
|
|
437
|
+
expect(batch.paths.size).toBe(3);
|
|
438
|
+
expect(batch.overflowed).toBe(true);
|
|
439
|
+
expect(batch.droppedPaths).toBe(7);
|
|
440
|
+
|
|
441
|
+
const source = fs.readFileSync(path.join(process.cwd(), "src/watcher.ts"), "utf8");
|
|
442
|
+
expect(source).not.toContain("startPollingTreeWatch");
|
|
443
|
+
expect(source).not.toContain("snapshotWatchTree");
|
|
444
|
+
});
|
|
377
445
|
});
|
|
378
446
|
|
|
379
447
|
describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", () => {
|
|
@@ -468,6 +536,55 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
|
468
536
|
});
|
|
469
537
|
}
|
|
470
538
|
|
|
539
|
+
it("F13: watcher batch event publishing applies bounded backpressure", async () => {
|
|
540
|
+
const paths = new Map<string, string>();
|
|
541
|
+
for (let i = 0; i < 32; i++) {
|
|
542
|
+
const rel = `bulk-${i}.md`;
|
|
543
|
+
const abs = path.join(dir, rel);
|
|
544
|
+
fs.writeFileSync(abs, `file ${i}`);
|
|
545
|
+
paths.set(abs, rel);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
let inFlight = 0;
|
|
549
|
+
let maxInFlight = 0;
|
|
550
|
+
let releaseGate!: () => void;
|
|
551
|
+
const gate = new Promise<void>((resolve) => {
|
|
552
|
+
releaseGate = resolve;
|
|
553
|
+
});
|
|
554
|
+
const transport: PushTransport = {
|
|
555
|
+
start: async () => {},
|
|
556
|
+
dispose: async () => {},
|
|
557
|
+
connected: true,
|
|
558
|
+
publish: async () => {
|
|
559
|
+
inFlight += 1;
|
|
560
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
561
|
+
await gate;
|
|
562
|
+
inFlight -= 1;
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
const emitter = new PushEventEmitter({
|
|
566
|
+
originTenantId: "tenant-indigo",
|
|
567
|
+
originDeviceId: "device-a",
|
|
568
|
+
transport,
|
|
569
|
+
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
570
|
+
now: () => new Date("2026-06-18T12:00:00.000Z"),
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const run = emitter.emitForBatch({ paths });
|
|
574
|
+
try {
|
|
575
|
+
for (let i = 0; i < 200 && maxInFlight <= 16; i++) {
|
|
576
|
+
await flushImmediate();
|
|
577
|
+
}
|
|
578
|
+
releaseGate();
|
|
579
|
+
await run;
|
|
580
|
+
|
|
581
|
+
expect(maxInFlight).toBeLessThanOrEqual(16);
|
|
582
|
+
} finally {
|
|
583
|
+
releaseGate();
|
|
584
|
+
await run.catch(() => {});
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
471
588
|
it("skips directory changes silently without publishing or reporting an error", async () => {
|
|
472
589
|
const published: PushEvent[] = [];
|
|
473
590
|
const onError = vi.fn();
|