@indigoai-us/hq-cloud 6.14.30 → 6.14.32

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