@indigoai-us/hq-cloud 6.11.11 → 6.11.13

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.
Files changed (220) hide show
  1. package/dist/bin/sync-runner-company.d.ts +35 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -0
  3. package/dist/bin/sync-runner-company.js +290 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -0
  5. package/dist/bin/sync-runner-events.d.ts +12 -0
  6. package/dist/bin/sync-runner-events.d.ts.map +1 -0
  7. package/dist/bin/sync-runner-events.js +12 -0
  8. package/dist/bin/sync-runner-events.js.map +1 -0
  9. package/dist/bin/sync-runner-planning.d.ts +53 -0
  10. package/dist/bin/sync-runner-planning.d.ts.map +1 -0
  11. package/dist/bin/sync-runner-planning.js +59 -0
  12. package/dist/bin/sync-runner-planning.js.map +1 -0
  13. package/dist/bin/sync-runner-rollup.d.ts +24 -0
  14. package/dist/bin/sync-runner-rollup.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-rollup.js +46 -0
  16. package/dist/bin/sync-runner-rollup.js.map +1 -0
  17. package/dist/bin/sync-runner-telemetry.d.ts +5 -0
  18. package/dist/bin/sync-runner-telemetry.d.ts.map +1 -0
  19. package/dist/bin/sync-runner-telemetry.js +5 -0
  20. package/dist/bin/sync-runner-telemetry.js.map +1 -0
  21. package/dist/bin/sync-runner-watch-loop.d.ts +17 -0
  22. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -0
  23. package/dist/bin/sync-runner-watch-loop.js +372 -0
  24. package/dist/bin/sync-runner-watch-loop.js.map +1 -0
  25. package/dist/bin/sync-runner-watch-routes.d.ts +25 -0
  26. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -0
  27. package/dist/bin/sync-runner-watch-routes.js +74 -0
  28. package/dist/bin/sync-runner-watch-routes.js.map +1 -0
  29. package/dist/bin/sync-runner.d.ts +5 -54
  30. package/dist/bin/sync-runner.d.ts.map +1 -1
  31. package/dist/bin/sync-runner.js +76 -978
  32. package/dist/bin/sync-runner.js.map +1 -1
  33. package/dist/bin/sync-runner.test.js +265 -11
  34. package/dist/bin/sync-runner.test.js.map +1 -1
  35. package/dist/cli/reindex.d.ts.map +1 -1
  36. package/dist/cli/reindex.js +34 -17
  37. package/dist/cli/reindex.js.map +1 -1
  38. package/dist/cli/reindex.test.js +39 -5
  39. package/dist/cli/reindex.test.js.map +1 -1
  40. package/dist/cli/rescue-classify-ordering.test.js +75 -0
  41. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  42. package/dist/cli/rescue-core.d.ts +45 -0
  43. package/dist/cli/rescue-core.d.ts.map +1 -1
  44. package/dist/cli/rescue-core.js +320 -170
  45. package/dist/cli/rescue-core.js.map +1 -1
  46. package/dist/cli/share.d.ts +2 -1
  47. package/dist/cli/share.d.ts.map +1 -1
  48. package/dist/cli/share.js +276 -660
  49. package/dist/cli/share.js.map +1 -1
  50. package/dist/cli/share.test.js +30 -0
  51. package/dist/cli/share.test.js.map +1 -1
  52. package/dist/cli/sync.d.ts +28 -1
  53. package/dist/cli/sync.d.ts.map +1 -1
  54. package/dist/cli/sync.js +541 -748
  55. package/dist/cli/sync.js.map +1 -1
  56. package/dist/cli/sync.test.js +382 -1
  57. package/dist/cli/sync.test.js.map +1 -1
  58. package/dist/cognito-auth.d.ts.map +1 -1
  59. package/dist/cognito-auth.js +55 -10
  60. package/dist/cognito-auth.js.map +1 -1
  61. package/dist/cognito-auth.test.js +61 -0
  62. package/dist/cognito-auth.test.js.map +1 -1
  63. package/dist/daemon-worker.d.ts +2 -2
  64. package/dist/daemon-worker.js +3 -3
  65. package/dist/daemon-worker.js.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/journal.d.ts.map +1 -1
  71. package/dist/journal.js +93 -6
  72. package/dist/journal.js.map +1 -1
  73. package/dist/journal.test.js +59 -0
  74. package/dist/journal.test.js.map +1 -1
  75. package/dist/machine-auth.test.js +60 -2
  76. package/dist/machine-auth.test.js.map +1 -1
  77. package/dist/object-io.d.ts +37 -1
  78. package/dist/object-io.d.ts.map +1 -1
  79. package/dist/object-io.js +149 -30
  80. package/dist/object-io.js.map +1 -1
  81. package/dist/object-io.test.js +121 -0
  82. package/dist/object-io.test.js.map +1 -1
  83. package/dist/operation-lock.d.ts +8 -8
  84. package/dist/operation-lock.d.ts.map +1 -1
  85. package/dist/operation-lock.js +99 -32
  86. package/dist/operation-lock.js.map +1 -1
  87. package/dist/operation-lock.test.js +51 -4
  88. package/dist/operation-lock.test.js.map +1 -1
  89. package/dist/personal-vault.d.ts.map +1 -1
  90. package/dist/personal-vault.js +8 -2
  91. package/dist/personal-vault.js.map +1 -1
  92. package/dist/personal-vault.test.js +34 -0
  93. package/dist/personal-vault.test.js.map +1 -1
  94. package/dist/prefix-coalesce.d.ts +20 -9
  95. package/dist/prefix-coalesce.d.ts.map +1 -1
  96. package/dist/prefix-coalesce.js +124 -28
  97. package/dist/prefix-coalesce.js.map +1 -1
  98. package/dist/prefix-coalesce.test.js +57 -2
  99. package/dist/prefix-coalesce.test.js.map +1 -1
  100. package/dist/remote-pull.d.ts +8 -3
  101. package/dist/remote-pull.d.ts.map +1 -1
  102. package/dist/remote-pull.js +85 -16
  103. package/dist/remote-pull.js.map +1 -1
  104. package/dist/remote-pull.test.js +213 -2
  105. package/dist/remote-pull.test.js.map +1 -1
  106. package/dist/s3.d.ts +2 -0
  107. package/dist/s3.d.ts.map +1 -1
  108. package/dist/s3.js +197 -116
  109. package/dist/s3.js.map +1 -1
  110. package/dist/s3.test.js +109 -0
  111. package/dist/s3.test.js.map +1 -1
  112. package/dist/scope-shrink.d.ts +3 -2
  113. package/dist/scope-shrink.d.ts.map +1 -1
  114. package/dist/scope-shrink.js +1 -1
  115. package/dist/scope-shrink.js.map +1 -1
  116. package/dist/skill-telemetry.d.ts +1 -1
  117. package/dist/skill-telemetry.d.ts.map +1 -1
  118. package/dist/skill-telemetry.js +69 -9
  119. package/dist/skill-telemetry.js.map +1 -1
  120. package/dist/skill-telemetry.test.js +86 -0
  121. package/dist/skill-telemetry.test.js.map +1 -1
  122. package/dist/sync/event-sync.d.ts +6 -0
  123. package/dist/sync/event-sync.d.ts.map +1 -1
  124. package/dist/sync/event-sync.js +34 -1
  125. package/dist/sync/event-sync.js.map +1 -1
  126. package/dist/sync/event-sync.test.js +73 -0
  127. package/dist/sync/event-sync.test.js.map +1 -1
  128. package/dist/sync/metrics.d.ts +17 -1
  129. package/dist/sync/metrics.d.ts.map +1 -1
  130. package/dist/sync/metrics.js +32 -1
  131. package/dist/sync/metrics.js.map +1 -1
  132. package/dist/sync/metrics.test.js +74 -1
  133. package/dist/sync/metrics.test.js.map +1 -1
  134. package/dist/sync/pull-scope.d.ts.map +1 -1
  135. package/dist/sync/pull-scope.js +15 -7
  136. package/dist/sync/pull-scope.js.map +1 -1
  137. package/dist/sync/push-receiver.d.ts +12 -5
  138. package/dist/sync/push-receiver.d.ts.map +1 -1
  139. package/dist/sync/push-receiver.js +45 -17
  140. package/dist/sync/push-receiver.js.map +1 -1
  141. package/dist/sync/push-receiver.test.js +67 -1
  142. package/dist/sync/push-receiver.test.js.map +1 -1
  143. package/dist/sync-core.d.ts +27 -0
  144. package/dist/sync-core.d.ts.map +1 -0
  145. package/dist/sync-core.js +54 -0
  146. package/dist/sync-core.js.map +1 -0
  147. package/dist/telemetry.d.ts +1 -1
  148. package/dist/telemetry.d.ts.map +1 -1
  149. package/dist/telemetry.js +59 -6
  150. package/dist/telemetry.js.map +1 -1
  151. package/dist/telemetry.test.js +74 -0
  152. package/dist/telemetry.test.js.map +1 -1
  153. package/dist/types.d.ts +8 -0
  154. package/dist/types.d.ts.map +1 -1
  155. package/dist/vault-client.d.ts.map +1 -1
  156. package/dist/vault-client.js +284 -36
  157. package/dist/vault-client.js.map +1 -1
  158. package/dist/vault-client.test.js +59 -0
  159. package/dist/vault-client.test.js.map +1 -1
  160. package/dist/watcher.d.ts +38 -20
  161. package/dist/watcher.d.ts.map +1 -1
  162. package/dist/watcher.js +155 -143
  163. package/dist/watcher.js.map +1 -1
  164. package/dist/watcher.test.js +103 -0
  165. package/dist/watcher.test.js.map +1 -1
  166. package/package.json +1 -1
  167. package/src/bin/sync-runner-company.ts +350 -0
  168. package/src/bin/sync-runner-events.ts +25 -0
  169. package/src/bin/sync-runner-planning.ts +121 -0
  170. package/src/bin/sync-runner-rollup.ts +72 -0
  171. package/src/bin/sync-runner-telemetry.ts +8 -0
  172. package/src/bin/sync-runner-watch-loop.ts +443 -0
  173. package/src/bin/sync-runner-watch-routes.ts +86 -0
  174. package/src/bin/sync-runner.test.ts +298 -11
  175. package/src/bin/sync-runner.ts +99 -1054
  176. package/src/cli/reindex.test.ts +41 -3
  177. package/src/cli/reindex.ts +35 -19
  178. package/src/cli/rescue-classify-ordering.test.ts +81 -0
  179. package/src/cli/rescue-core.ts +400 -165
  180. package/src/cli/share.test.ts +38 -0
  181. package/src/cli/share.ts +420 -693
  182. package/src/cli/sync.test.ts +460 -1
  183. package/src/cli/sync.ts +788 -825
  184. package/src/cognito-auth.test.ts +77 -0
  185. package/src/cognito-auth.ts +73 -11
  186. package/src/daemon-worker.ts +3 -3
  187. package/src/index.ts +8 -0
  188. package/src/journal.test.ts +72 -0
  189. package/src/journal.ts +95 -8
  190. package/src/machine-auth.test.ts +64 -2
  191. package/src/object-io.test.ts +142 -0
  192. package/src/object-io.ts +183 -31
  193. package/src/operation-lock.test.ts +63 -4
  194. package/src/operation-lock.ts +99 -31
  195. package/src/personal-vault.test.ts +42 -0
  196. package/src/personal-vault.ts +8 -2
  197. package/src/prefix-coalesce.test.ts +71 -1
  198. package/src/prefix-coalesce.ts +155 -30
  199. package/src/remote-pull.test.ts +235 -1
  200. package/src/remote-pull.ts +106 -18
  201. package/src/s3.test.ts +126 -0
  202. package/src/s3.ts +237 -122
  203. package/src/scope-shrink.ts +6 -3
  204. package/src/skill-telemetry.test.ts +109 -0
  205. package/src/skill-telemetry.ts +82 -14
  206. package/src/sync/event-sync.test.ts +75 -0
  207. package/src/sync/event-sync.ts +54 -1
  208. package/src/sync/metrics.test.ts +81 -0
  209. package/src/sync/metrics.ts +59 -4
  210. package/src/sync/pull-scope.ts +23 -7
  211. package/src/sync/push-receiver.test.ts +73 -1
  212. package/src/sync/push-receiver.ts +56 -20
  213. package/src/sync-core.ts +58 -0
  214. package/src/telemetry.test.ts +85 -0
  215. package/src/telemetry.ts +69 -6
  216. package/src/types.ts +8 -0
  217. package/src/vault-client.test.ts +74 -0
  218. package/src/vault-client.ts +395 -43
  219. package/src/watcher.test.ts +117 -0
  220. package/src/watcher.ts +215 -174
@@ -125,6 +125,39 @@ export const DEFAULT_MAX_MESSAGES = 10;
125
125
  export const DEFAULT_RECONNECT_INITIAL_MS = 250;
126
126
  export const DEFAULT_RECONNECT_MAX_MS = 30_000;
127
127
 
128
+ /** Maximum distinct paths retained in receiver dedupe state. */
129
+ export const DEFAULT_RECEIVER_DEDUPE_MAX_PATHS = 50_000;
130
+
131
+ class BoundedSequenceDedupe {
132
+ private readonly maxPaths: number;
133
+ private readonly seen = new Map<string, number>();
134
+
135
+ constructor(maxPaths: number | undefined) {
136
+ this.maxPaths = Math.max(
137
+ 1,
138
+ Math.floor(maxPaths ?? DEFAULT_RECEIVER_DEDUPE_MAX_PATHS),
139
+ );
140
+ }
141
+
142
+ get(relativePath: string): number | undefined {
143
+ const sequenceNumber = this.seen.get(relativePath);
144
+ if (sequenceNumber === undefined) return undefined;
145
+ this.seen.delete(relativePath);
146
+ this.seen.set(relativePath, sequenceNumber);
147
+ return sequenceNumber;
148
+ }
149
+
150
+ set(relativePath: string, sequenceNumber: number): void {
151
+ if (this.seen.has(relativePath)) this.seen.delete(relativePath);
152
+ this.seen.set(relativePath, sequenceNumber);
153
+ while (this.seen.size > this.maxPaths) {
154
+ const oldest = this.seen.keys().next().value;
155
+ if (oldest === undefined) return;
156
+ this.seen.delete(oldest);
157
+ }
158
+ }
159
+ }
160
+
128
161
  // ─── Narrow SQS surface (the injectable transport seam) ──────────────────────
129
162
 
130
163
  /**
@@ -191,8 +224,8 @@ export interface PushReceiverContext {
191
224
  * company/path; in tests it's a fake recording invocations.
192
225
  *
193
226
  * Errors from `syncFn` are CAUGHT by the receiver — they log and the loop
194
- * continues. A misbehaving sync fn cannot crash the receiver. The poll/cadence
195
- * safety net is the recovery path for a thrown pull.
227
+ * continues. A failed sync is left unacknowledged in SQS so the queue can
228
+ * redeliver it after the visibility timeout.
196
229
  */
197
230
  export type SyncEngineFn = (ctx: PushReceiverContext) => Promise<void>;
198
231
 
@@ -321,6 +354,8 @@ export interface SqsPushReceiverOptions {
321
354
  maxMessages?: number;
322
355
  /** Max time `dispose()` waits for an in-flight syncFn after abort. */
323
356
  disposeDrainMs?: number;
357
+ /** Maximum distinct paths retained in dedupe state. */
358
+ dedupeMaxPaths?: number;
324
359
  /** Reconnect backoff config. */
325
360
  reconnect?: {
326
361
  initialMs?: number;
@@ -384,7 +419,7 @@ export class SqsPushReceiver implements PushReceiver {
384
419
  private inFlightSync: Promise<void> | null = null;
385
420
 
386
421
  /** Per-path highest sequence number already PROCESSED by syncFn. */
387
- private readonly seenSequencePerPath = new Map<string, number>();
422
+ private readonly seenSequencePerPath: BoundedSequenceDedupe;
388
423
 
389
424
  private _processedCount = 0;
390
425
  private _dedupedCount = 0;
@@ -413,6 +448,7 @@ export class SqsPushReceiver implements PushReceiver {
413
448
  this.maxMessages = opts.maxMessages ?? DEFAULT_MAX_MESSAGES;
414
449
  this.disposeDrainMs =
415
450
  opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
451
+ this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
416
452
  this.reconnectInitialMs =
417
453
  opts.reconnect?.initialMs ?? DEFAULT_RECONNECT_INITIAL_MS;
418
454
  this.reconnectMaxMs = opts.reconnect?.maxMs ?? DEFAULT_RECONNECT_MAX_MS;
@@ -613,18 +649,18 @@ export class SqsPushReceiver implements PushReceiver {
613
649
 
614
650
  const handled = await this.dispatch(validated);
615
651
  if (handled) {
616
- // Delete only after the handoff (success OR dedupe-skip both mean we
617
- // don't need this message again). A syncFn throw still counts as handled
618
- // for delete purposes: the seen-counter advanced, so a redelivery would
619
- // dedupe; the poll/cadence safety net is the recovery path for the throw.
652
+ // Delete only after a successful handoff or dedupe-skip. A syncFn throw
653
+ // leaves the message undeleted so SQS redelivery can retry the targeted
654
+ // pull.
620
655
  await this.safeDelete(msg);
621
656
  }
622
657
  }
623
658
 
624
659
  /**
625
- * Dedupe + invoke `syncFn`. Returns true once the event has been accounted
626
- * for (deduped, or syncFn settled) so the caller can delete the message.
627
- * Stores the in-flight promise so `dispose()` can drain it.
660
+ * Dedupe + invoke `syncFn`. Returns true only once the event has been
661
+ * accounted for successfully (deduped, or syncFn completed) so the caller can
662
+ * delete the message. Stores the in-flight promise so `dispose()` can drain
663
+ * it.
628
664
  */
629
665
  private async dispatch(event: PushEvent): Promise<boolean> {
630
666
  if (this.disposing || this.disposed) return false;
@@ -645,18 +681,13 @@ export class SqsPushReceiver implements PushReceiver {
645
681
  return true;
646
682
  }
647
683
 
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
684
  const controller = new AbortController();
655
685
  this.inFlightAbort = controller;
656
686
  const ctx: PushReceiverContext = { event, signal: controller.signal };
657
687
 
658
688
  const holder: { p: Promise<void> | null } = { p: null };
659
689
  const startMs = this.now();
690
+ let handled = false;
660
691
  const p: Promise<void> = (async () => {
661
692
  try {
662
693
  this.logger.debug(
@@ -669,7 +700,9 @@ export class SqsPushReceiver implements PushReceiver {
669
700
  "push receiver invoking sync engine",
670
701
  );
671
702
  await this.syncFn(ctx);
703
+ this.seenSequencePerPath.set(event.relativePath, event.sequenceNumber);
672
704
  this._processedCount += 1;
705
+ handled = true;
673
706
  this.logger.debug(
674
707
  {
675
708
  event: "receiver.sync.completed",
@@ -715,8 +748,8 @@ export class SqsPushReceiver implements PushReceiver {
715
748
  });
716
749
  } catch (err) {
717
750
  // Critical: catch, log, return. A misbehaving sync engine must never
718
- // crash the receiver loop. The safety-net poll handles eventual
719
- // consistency for failed pulls.
751
+ // crash the receiver loop. Leaving the SQS message undeleted lets
752
+ // redelivery retry the failed targeted pull.
720
753
  const e = err as NodeJS.ErrnoException;
721
754
  this.logger.error(
722
755
  {
@@ -736,7 +769,7 @@ export class SqsPushReceiver implements PushReceiver {
736
769
  holder.p = p;
737
770
  this.inFlightSync = p;
738
771
  await p;
739
- return true;
772
+ return handled;
740
773
  }
741
774
 
742
775
  /** Delete a message, swallowing transport errors (redelivery is harmless). */
@@ -824,6 +857,8 @@ export interface InMemoryPushReceiverOptions {
824
857
  flagProvider?: EventDrivenPushFlagProvider;
825
858
  env?: Record<string, string | undefined>;
826
859
  disposeDrainMs?: number;
860
+ /** Maximum distinct paths retained in dedupe state. */
861
+ dedupeMaxPaths?: number;
827
862
  }
828
863
 
829
864
  /**
@@ -849,7 +884,7 @@ export class InMemoryPushReceiver implements PushReceiver {
849
884
 
850
885
  private disconnectedFlag = false;
851
886
  private readonly pendingDuringDisconnect: PushEvent[] = [];
852
- private readonly seenSequencePerPath = new Map<string, number>();
887
+ private readonly seenSequencePerPath: BoundedSequenceDedupe;
853
888
 
854
889
  private inFlightAbort: AbortController | null = null;
855
890
  private inFlightSync: Promise<void> | null = null;
@@ -865,6 +900,7 @@ export class InMemoryPushReceiver implements PushReceiver {
865
900
  this.logger = opts.logger ?? NOOP_LOGGER;
866
901
  this.disposeDrainMs =
867
902
  opts.disposeDrainMs ?? DEFAULT_RECEIVER_DISPOSE_DRAIN_MS;
903
+ this.seenSequencePerPath = new BoundedSequenceDedupe(opts.dedupeMaxPaths);
868
904
  this.enabled = resolveEnabled({
869
905
  explicit: opts.enabled,
870
906
  flagProvider: opts.flagProvider,
@@ -0,0 +1,58 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { normalizeEtag } from "./journal.js";
4
+
5
+ /**
6
+ * Is this error the S3/STS "access denied" class? Expected when a scoped
7
+ * member/guest credential touches a key outside its granted ACL prefixes
8
+ * (the server's `SCOPE_EXCEEDS_PARENT` surfaces as a 403 AccessDenied /
9
+ * Forbidden).
10
+ */
11
+ export function isAccessDenied(err: unknown): boolean {
12
+ if (err && typeof err === "object" && "name" in err) {
13
+ const name = (err as { name?: unknown }).name;
14
+ return name === "AccessDenied" || name === "Forbidden";
15
+ }
16
+ return false;
17
+ }
18
+
19
+ export function resolveTransferConcurrency(): number {
20
+ const raw = process.env.HQ_SYNC_TRANSFER_CONCURRENCY;
21
+ if (raw === undefined || raw === "") return 16;
22
+ const parsed = Number.parseInt(raw, 10);
23
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 16;
24
+ }
25
+
26
+ /**
27
+ * Resolve active company from .hq/config.json.
28
+ */
29
+ export function resolveActiveCompany(hqRoot: string): string | undefined {
30
+ const configPath = path.join(hqRoot, ".hq", "config.json");
31
+ if (fs.existsSync(configPath)) {
32
+ try {
33
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
34
+ return config.activeCompany ?? config.companySlug;
35
+ } catch {
36
+ // Ignore parse errors
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * Returns true when the remote object appears to have moved since the
44
+ * journal entry's last-recorded sync. Prefers ETag equality; falls back to
45
+ * `lastModified > syncedAt` for legacy entries written before remoteEtag
46
+ * was tracked. Conservative on tie (`<=` skews "remote unchanged") so an
47
+ * S3-side mtime that exactly equals our syncedAt is not treated as drift.
48
+ */
49
+ export function hasRemoteChanged(
50
+ remote: { lastModified: Date; etag: string },
51
+ entry: { syncedAt: string; remoteEtag?: string },
52
+ ): boolean {
53
+ if (entry.remoteEtag) {
54
+ return normalizeEtag(remote.etag) !== entry.remoteEtag;
55
+ }
56
+ const syncedAt = new Date(entry.syncedAt).getTime();
57
+ return remote.lastModified.getTime() > syncedAt;
58
+ }
@@ -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 ≤1 MiB POST bodies, and ships them to `/v1/usage`.
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 MAX_BATCH_BYTES = 1_000_000;
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 1 MiB.
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(sanitized), "utf-8");
507
+ const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
449
508
  const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
450
509
 
451
- if (batchEvents.length > 0 && batchBytes + addCost > MAX_BATCH_BYTES) {
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(sanitized);
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
  /**
@@ -440,6 +440,80 @@ describe("API surface", () => {
440
440
  });
441
441
  });
442
442
 
443
+ describe("response validation (F26)", () => {
444
+ it("F26: validates a valid files/list response and returns typed data", async () => {
445
+ fetchSpy.mockResolvedValueOnce(
446
+ jsonResponse(200, {
447
+ objects: [
448
+ {
449
+ key: "shared/docs/a.txt",
450
+ size: 123,
451
+ lastModified: "2026-05-20T12:00:00.000Z",
452
+ etag: "abc123",
453
+ permission: "read",
454
+ },
455
+ ],
456
+ cursor: null,
457
+ truncated: false,
458
+ }),
459
+ );
460
+
461
+ const page = await client.listFiles("cmp_abc", "shared/docs/");
462
+
463
+ expect(page.objects[0]).toEqual({
464
+ key: "shared/docs/a.txt",
465
+ size: 123,
466
+ lastModified: "2026-05-20T12:00:00.000Z",
467
+ etag: "abc123",
468
+ permission: "read",
469
+ });
470
+ expect(page.cursor).toBeNull();
471
+ expect(page.truncated).toBe(false);
472
+ });
473
+
474
+ it("F26: defaults omitted files/list paging fields", async () => {
475
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
476
+
477
+ const page = await client.listFiles("cmp_abc", "shared/docs/");
478
+
479
+ expect(page).toEqual({
480
+ objects: [],
481
+ cursor: null,
482
+ truncated: false,
483
+ });
484
+ });
485
+
486
+ it("F26: defaults omitted telemetry opt-in fields to disabled", async () => {
487
+ fetchSpy.mockResolvedValueOnce(jsonResponse(200, {}));
488
+
489
+ const optIn = await client.getTelemetryOptIn();
490
+
491
+ expect(optIn).toEqual({ enabled: false, updatedAt: null });
492
+ });
493
+
494
+ it("F26: maps a malformed files/list response to VaultClientError", async () => {
495
+ fetchSpy.mockResolvedValueOnce(
496
+ jsonResponse(200, {
497
+ objects: [
498
+ {
499
+ key: "shared/docs/a.txt",
500
+ size: 123,
501
+ lastModified: "2026-05-20T12:00:00.000Z",
502
+ permission: "read",
503
+ },
504
+ ],
505
+ cursor: null,
506
+ truncated: false,
507
+ }),
508
+ );
509
+
510
+ await expect(client.listFiles("cmp_abc")).rejects.toMatchObject({
511
+ name: "VaultClientError",
512
+ statusCode: 502,
513
+ });
514
+ });
515
+ });
516
+
443
517
  describe("VaultClient identity bootstrap", () => {
444
518
  let client: VaultClient;
445
519
  let fetchSpy: MockInstance<typeof fetch>;