@indigoai-us/hq-cloud 6.14.30 → 6.14.31

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/src/telemetry.ts CHANGED
@@ -77,6 +77,10 @@ export interface CollectTelemetryOptions {
77
77
  claudeProjectsRoot?: string;
78
78
  /** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
79
79
  codexRoot?: string;
80
+ /** Override the per-runtime scan budget for deterministic tests. */
81
+ maxScanBytesPerSource?: number;
82
+ /** Override the upload batch cap for deterministic tests. */
83
+ maxBatchesPerRun?: number;
80
84
  /** Override `~/.hq/telemetry-cursor.json` for tests. */
81
85
  cursorPath?: string;
82
86
  /** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
@@ -107,6 +111,7 @@ interface CodexUsageContext {
107
111
  session_model?: string;
108
112
  collaboration_model?: string;
109
113
  turn_model?: string;
114
+ discarding_partial_line?: boolean;
110
115
  }
111
116
 
112
117
  interface CursorEntry {
@@ -545,13 +550,32 @@ function codexUsageRow(
545
550
  return normalized;
546
551
  }
547
552
 
553
+ async function sortFilesFreshestFirst(files: string[]): Promise<string[]> {
554
+ const ranked = await Promise.all(
555
+ files.map(async (filePath) => {
556
+ try {
557
+ return { filePath, mtime: (await fs.stat(filePath)).mtimeMs };
558
+ } catch {
559
+ return { filePath, mtime: 0 };
560
+ }
561
+ }),
562
+ );
563
+ ranked.sort(
564
+ (left, right) =>
565
+ right.mtime - left.mtime || left.filePath.localeCompare(right.filePath),
566
+ );
567
+ return ranked.map(({ filePath }) => filePath);
568
+ }
569
+
548
570
  async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
549
571
  const [live, archived] = await Promise.all([
550
572
  listJsonlFiles(path.join(codexRoot, "sessions")),
551
573
  listJsonlFiles(path.join(codexRoot, "archived_sessions")),
552
574
  ]);
553
- return [...live, ...archived].filter((file) =>
554
- path.basename(file).startsWith("rollout-"),
575
+ return sortFilesFreshestFirst(
576
+ [...live, ...archived].filter((file) =>
577
+ path.basename(file).startsWith("rollout-"),
578
+ ),
555
579
  );
556
580
  }
557
581
 
@@ -559,6 +583,9 @@ async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
559
583
 
560
584
  const MAX_BATCH_EVENTS = 100;
561
585
  const MAX_BATCH_BYTES = 240 * 1024;
586
+ const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
587
+ const MAX_BATCHES_PER_RUN = 4;
588
+ const MAX_PARTIAL_LINE_BYTES = 64 * 1024;
562
589
  const ROW_TRUNCATION_SUFFIX = "...[truncated]";
563
590
 
564
591
  interface RowSource {
@@ -650,6 +677,9 @@ export async function collectAndSendTelemetry(
650
677
  const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
651
678
  const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
652
679
  const log = opts.log ?? (() => {});
680
+ const maxScanBytesPerSource =
681
+ opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE;
682
+ const maxBatchesPerRun = opts.maxBatchesPerRun ?? MAX_BATCHES_PER_RUN;
653
683
 
654
684
  // Company attribution (US-002): parse the manifest ONCE per run and reuse the
655
685
  // repo-path→companyUid map for every event below. No per-event manifest read.
@@ -745,13 +775,16 @@ export async function collectAndSendTelemetry(
745
775
  const rotationResets: Record<string, CursorEntry> = {};
746
776
  const newlyCommitted: Record<string, CursorEntry> = {};
747
777
 
748
- const [claudeFiles, codexFiles] = await Promise.all([
778
+ const [claudeFilesUnsorted, codexFiles] = await Promise.all([
749
779
  listJsonlFiles(claudeProjectsRoot),
750
780
  listCodexRolloutFiles(codexRoot),
751
781
  ]);
782
+ const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
783
+ // Codex goes first so a fresh Linux install cannot spend its entire bounded
784
+ // pass replaying an older Claude backlog before reporting current Codex use.
752
785
  const files: Array<{ filePath: string; kind: "claude" | "codex" }> = [
753
- ...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
754
786
  ...codexFiles.map((filePath) => ({ filePath, kind: "codex" as const })),
787
+ ...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
755
788
  ];
756
789
 
757
790
  // 3. Walk each file, sanitize new rows, batch, flush at the server contract.
@@ -768,15 +801,45 @@ export async function collectAndSendTelemetry(
768
801
  let batchBytes = ENVELOPE_BYTES;
769
802
  let eventsSent = 0;
770
803
  let batchesSent = 0;
804
+ let uploadFailed = false;
805
+ let batchLimitReached = false;
806
+ const scannedBytes = { claude: 0, codex: 0 };
807
+
808
+ const commitSources = (sources: RowSource[]): void => {
809
+ const maxPerFile = new Map<
810
+ string,
811
+ { mtime: number; offset: number; context?: CodexUsageContext }
812
+ >();
813
+ for (const source of sources) {
814
+ const current = maxPerFile.get(source.filePath);
815
+ if (!current || source.endOffset > current.offset) {
816
+ maxPerFile.set(source.filePath, {
817
+ mtime: source.mtime,
818
+ offset: source.endOffset,
819
+ ...(source.context
820
+ ? { context: cloneCodexContext(source.context) }
821
+ : {}),
822
+ });
823
+ }
824
+ }
825
+ for (const [filePath, entry] of maxPerFile) {
826
+ const current = newlyCommitted[filePath];
827
+ if (!current || entry.offset > current.offset) {
828
+ newlyCommitted[filePath] = entry;
829
+ }
830
+ }
831
+ };
771
832
 
772
- const flush = async (): Promise<void> => {
773
- if (batchEvents.length === 0) return;
833
+ const flush = async (): Promise<boolean> => {
774
834
  const events = batchEvents;
775
835
  const sources = batchSources;
776
836
  batchEvents = [];
777
837
  batchSources = [];
778
838
  batchBytes = ENVELOPE_BYTES;
779
-
839
+ if (events.length === 0) {
840
+ commitSources(sources);
841
+ return true;
842
+ }
780
843
  try {
781
844
  await opts.client.postUsage({
782
845
  machineId: opts.machineId,
@@ -785,35 +848,26 @@ export async function collectAndSendTelemetry(
785
848
  });
786
849
  batchesSent++;
787
850
  eventsSent += events.length;
788
- // Advance cursor to max(endOffset) per file in this batch.
789
- const maxPerFile = new Map<
790
- string,
791
- { mtime: number; offset: number; context?: CodexUsageContext }
792
- >();
793
- for (const src of sources) {
794
- const cur = maxPerFile.get(src.filePath);
795
- if (!cur || src.endOffset > cur.offset) {
796
- maxPerFile.set(src.filePath, {
797
- mtime: src.mtime,
798
- offset: src.endOffset,
799
- ...(src.context ? { context: cloneCodexContext(src.context) } : {}),
800
- });
801
- }
802
- }
803
- for (const [fp, entry] of maxPerFile) {
804
- newlyCommitted[fp] = {
805
- offset: entry.offset,
806
- mtime: entry.mtime,
807
- ...(entry.context ? { context: entry.context } : {}),
808
- };
809
- }
851
+ commitSources(sources);
852
+ return true;
810
853
  } catch (err) {
811
- log(`[telemetry] postUsage failed (${(err as Error).message ?? err}) — cursor not advanced for ${sources.length} rows`);
812
- // Cursor intentionally left un-advanced — next sync retries.
854
+ log(
855
+ "[telemetry] postUsage failed (" +
856
+ ((err as Error).message ?? err) +
857
+ ") — cursor not advanced for " +
858
+ sources.length +
859
+ " rows",
860
+ );
861
+ return false;
813
862
  }
814
863
  };
815
864
 
816
- for (const { filePath, kind } of files) {
865
+ filesLoop: for (const { filePath, kind } of files) {
866
+ if (uploadFailed || batchLimitReached) break;
867
+ const sourceBudgetRemaining =
868
+ maxScanBytesPerSource - scannedBytes[kind];
869
+ if (sourceBudgetRemaining <= 0) continue;
870
+
817
871
  let stat;
818
872
  try {
819
873
  stat = await fs.stat(filePath);
@@ -822,12 +876,10 @@ export async function collectAndSendTelemetry(
822
876
  }
823
877
  const currentSize = stat.size;
824
878
  const currentMtime = Math.floor(stat.mtimeMs / 1000);
825
-
826
879
  const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
827
880
  let offset = stored.offset;
828
881
  let codexContext: CodexUsageContext | undefined =
829
882
  kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
830
-
831
883
  const rotated =
832
884
  currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
833
885
  if (rotated) {
@@ -835,44 +887,117 @@ export async function collectAndSendTelemetry(
835
887
  if (kind === "codex") codexContext = {};
836
888
  rotationResets[filePath] = { offset: 0, mtime: currentMtime };
837
889
  }
838
-
839
890
  if (offset >= currentSize && !rotated) continue;
840
891
 
841
892
  let content: string;
893
+ let bytesRead = 0;
894
+ let readBuffer = Buffer.alloc(0);
842
895
  try {
843
- const fh = await fs.open(filePath, "r");
896
+ const fileHandle = await fs.open(filePath, "r");
844
897
  try {
845
- const length = Math.max(0, currentSize - offset);
846
- const buf = Buffer.alloc(length);
847
- await fh.read(buf, 0, length, offset);
848
- content = buf.toString("utf-8");
898
+ const length = Math.max(
899
+ 0,
900
+ Math.min(currentSize - offset, sourceBudgetRemaining),
901
+ );
902
+ readBuffer = Buffer.alloc(length);
903
+ ({ bytesRead } = await fileHandle.read(
904
+ readBuffer,
905
+ 0,
906
+ length,
907
+ offset,
908
+ ));
909
+ content = readBuffer.toString("utf-8", 0, bytesRead);
849
910
  } finally {
850
- await fh.close();
911
+ await fileHandle.close();
851
912
  }
852
913
  } catch {
853
914
  continue;
854
915
  }
855
- if (content.length === 0) continue;
916
+ if (bytesRead === 0) continue;
917
+
918
+ // Resume a previously discarded oversized Codex row using raw byte
919
+ // positions. Decoding from the middle of a multibyte UTF-8 codepoint would
920
+ // otherwise skew byte offsets and could skip or replay later rows.
921
+ if (codexContext?.discarding_partial_line) {
922
+ const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
923
+ const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
924
+ codexContext.discarding_partial_line = newlineIndex < 0;
925
+ scannedBytes[kind] += discardedBytes;
926
+ batchSources.push({
927
+ filePath,
928
+ endOffset: offset + discardedBytes,
929
+ mtime: currentMtime,
930
+ context: cloneCodexContext(codexContext),
931
+ });
932
+ if (newlineIndex < 0) continue;
933
+ offset += discardedBytes;
934
+ bytesRead -= discardedBytes;
935
+ readBuffer = readBuffer.subarray(discardedBytes);
936
+ content = readBuffer.toString("utf-8", 0, bytesRead);
937
+ if (bytesRead === 0) continue;
938
+ }
856
939
 
940
+ const reachedPhysicalEof = offset + bytesRead >= currentSize;
857
941
  const segments = content.split("\n");
858
942
  const lineEndOffsets: number[] = [];
859
943
  let cumulative = 0;
860
944
  for (let i = 0; i < segments.length; i++) {
861
945
  cumulative += Buffer.byteLength(segments[i], "utf-8");
862
- if (i < segments.length - 1) cumulative += 1; // newline byte
946
+ if (i < segments.length - 1) cumulative += 1;
863
947
  lineEndOffsets.push(offset + cumulative);
864
948
  }
865
949
 
866
950
  for (let i = 0; i < segments.length; i++) {
951
+ const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
952
+ const hasNewline = i < segments.length - 1;
953
+ const endOffset =
954
+ !hasNewline && !reachedPhysicalEof
955
+ ? offset + bytesRead
956
+ : lineEndOffsets[i];
957
+ const lineBytes = endOffset - startOffset;
958
+ if (!hasNewline && !reachedPhysicalEof) {
959
+ const discarding = codexContext?.discarding_partial_line === true;
960
+ if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
961
+ if (codexContext) codexContext.discarding_partial_line = true;
962
+ scannedBytes[kind] += lineBytes;
963
+ batchSources.push({
964
+ filePath,
965
+ endOffset,
966
+ mtime: currentMtime,
967
+ ...(codexContext
968
+ ? { context: cloneCodexContext(codexContext) }
969
+ : {}),
970
+ });
971
+ }
972
+ break;
973
+ }
974
+
975
+ scannedBytes[kind] += lineBytes;
976
+ const source = (): RowSource => ({
977
+ filePath,
978
+ endOffset,
979
+ mtime: currentMtime,
980
+ ...(codexContext
981
+ ? { context: cloneCodexContext(codexContext) }
982
+ : {}),
983
+ });
984
+ if (codexContext?.discarding_partial_line) {
985
+ codexContext.discarding_partial_line = false;
986
+ batchSources.push(source());
987
+ continue;
988
+ }
867
989
  const trimmed = segments[i].trim();
868
- if (trimmed.length === 0) continue;
990
+ if (trimmed.length === 0) {
991
+ batchSources.push(source());
992
+ continue;
993
+ }
869
994
  let parsed: unknown;
870
995
  try {
871
996
  parsed = JSON.parse(trimmed);
872
997
  } catch {
998
+ batchSources.push(source());
873
999
  continue;
874
1000
  }
875
- const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
876
1001
  const sourceRow =
877
1002
  kind === "codex"
878
1003
  ? codexUsageRow(
@@ -880,11 +1005,9 @@ export async function collectAndSendTelemetry(
880
1005
  codexContext!,
881
1006
  filePath,
882
1007
  startOffset,
883
- lineEndOffsets[i],
1008
+ endOffset,
884
1009
  )
885
1010
  : parsed;
886
- // Resolve this row's cwd → owning company (cmp_* uid) before sanitizing,
887
- // using the per-run map. Unresolved → undefined → companyUid omitted.
888
1011
  const rowCwd =
889
1012
  sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
890
1013
  ? (sourceRow as Record<string, unknown>).cwd
@@ -894,52 +1017,62 @@ export async function collectAndSendTelemetry(
894
1017
  repoCompanyMap,
895
1018
  );
896
1019
  const sanitized = sanitizeRow(sourceRow, companyUid);
897
- if (!sanitized) continue;
1020
+ if (!sanitized) {
1021
+ batchSources.push(source());
1022
+ continue;
1023
+ }
1024
+
898
1025
  const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
899
1026
  const wasOversized = jsonBytes(sanitized) > maxRowBytes;
900
1027
  const bounded = boundRowForPost(sanitized, maxRowBytes);
901
1028
  if (!bounded) {
902
1029
  log(
903
- `[telemetry] oversized row dropped before send (${filePath}:${i + 1})`,
1030
+ "[telemetry] oversized row dropped before send (" +
1031
+ filePath +
1032
+ ":" +
1033
+ (i + 1) +
1034
+ ")",
904
1035
  );
1036
+ batchSources.push(source());
905
1037
  continue;
906
1038
  }
907
1039
  if (wasOversized) {
908
1040
  log(
909
- `[telemetry] oversized row truncated before send (${filePath}:${i + 1})`,
1041
+ "[telemetry] oversized row truncated before send (" +
1042
+ filePath +
1043
+ ":" +
1044
+ (i + 1) +
1045
+ ")",
910
1046
  );
911
1047
  }
912
1048
 
913
- // Cost of appending this row to the current batch: the row's JSON
914
- // length plus 1 byte for the leading comma when there's already at
915
- // least one row. (No comma when the batch is empty — the row sits
916
- // alone inside the events array.)
917
- const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
1049
+ const rowJsonBytes = jsonBytes(bounded);
918
1050
  const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
919
-
920
1051
  if (
921
1052
  batchEvents.length > 0 &&
922
1053
  (batchEvents.length >= MAX_BATCH_EVENTS ||
923
1054
  batchBytes + addCost > MAX_BATCH_BYTES)
924
1055
  ) {
925
- await flush();
926
- // After flush, batchEvents is empty → no comma needed for the first row.
1056
+ if (!(await flush())) {
1057
+ uploadFailed = true;
1058
+ break filesLoop;
1059
+ }
1060
+ if (batchesSent >= maxBatchesPerRun) {
1061
+ batchLimitReached = true;
1062
+ break filesLoop;
1063
+ }
927
1064
  batchBytes = ENVELOPE_BYTES + rowJsonBytes;
928
1065
  } else {
929
1066
  batchBytes += addCost;
930
1067
  }
931
-
932
1068
  batchEvents.push(bounded);
933
- batchSources.push({
934
- filePath,
935
- endOffset: lineEndOffsets[i],
936
- mtime: currentMtime,
937
- ...(codexContext ? { context: cloneCodexContext(codexContext) } : {}),
938
- });
1069
+ batchSources.push(source());
939
1070
  }
940
1071
  }
941
1072
 
942
- await flush();
1073
+ if (!uploadFailed && !batchLimitReached) {
1074
+ uploadFailed = !(await flush());
1075
+ }
943
1076
 
944
1077
  // 4. Persist cursor: loaded < rotation_resets < newly_committed.
945
1078
  const finalFiles: Record<string, CursorEntry> = { ...loadedFiles };