@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/dist/bin/sync-runner.d.ts +1 -1
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +11 -7
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +6 -3
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/qmd-reindex.d.ts +6 -3
- package/dist/qmd-reindex.d.ts.map +1 -1
- package/dist/qmd-reindex.js +84 -7
- package/dist/qmd-reindex.js.map +1 -1
- package/dist/qmd-reindex.test.js +171 -5
- package/dist/qmd-reindex.test.js.map +1 -1
- package/dist/telemetry.d.ts +10 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +174 -58
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +96 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +6 -3
- package/src/bin/sync-runner.ts +13 -5
- package/src/qmd-reindex.test.ts +183 -5
- package/src/qmd-reindex.ts +89 -7
- package/src/telemetry.test.ts +121 -0
- package/src/telemetry.ts +216 -71
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
|
|
554
|
-
|
|
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 [
|
|
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<
|
|
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
|
-
|
|
789
|
-
|
|
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(
|
|
812
|
-
|
|
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
|
|
907
|
+
const fileHandle = await fs.open(filePath, "r");
|
|
844
908
|
try {
|
|
845
|
-
const length = Math.max(
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
|
922
|
+
await fileHandle.close();
|
|
851
923
|
}
|
|
852
924
|
} catch {
|
|
853
925
|
continue;
|
|
854
926
|
}
|
|
855
|
-
if (
|
|
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;
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
893
|
-
|
|
894
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1053
|
+
"[telemetry] oversized row truncated before send (" +
|
|
1054
|
+
filePath +
|
|
1055
|
+
":" +
|
|
1056
|
+
(i + 1) +
|
|
1057
|
+
")",
|
|
910
1058
|
);
|
|
911
1059
|
}
|
|
912
1060
|
|
|
913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|