@bitkyc08/opencodex 2.14.1 → 2.14.2

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 (44) hide show
  1. package/gui/dist/assets/{index-DWhX3yMp.css → index-DUCH59lJ.css} +1 -1
  2. package/gui/dist/assets/index-DUyQeU1j.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/command-code.ts +15 -4
  6. package/src/adapters/cursor/request-builder.ts +54 -10
  7. package/src/adapters/cursor/tool-definitions.ts +24 -0
  8. package/src/adapters/kiro.ts +10 -1
  9. package/src/adapters/openai-chat.ts +5 -3
  10. package/src/adapters/openai-responses.ts +109 -0
  11. package/src/adapters/tool-catalog-nudge.ts +26 -4
  12. package/src/bridge.ts +50 -3
  13. package/src/cli/init.ts +4 -17
  14. package/src/codex/catalog/effort.ts +2 -1
  15. package/src/codex/catalog/metadata.ts +62 -12
  16. package/src/codex/catalog/native-models.ts +27 -0
  17. package/src/codex/catalog/parsing.ts +17 -2
  18. package/src/codex/catalog/provider-fetch.ts +47 -5
  19. package/src/codex/catalog/sync.ts +21 -7
  20. package/src/codex/catalog.ts +1 -1
  21. package/src/config.ts +79 -4
  22. package/src/generated/compatibility-version.json +48 -36
  23. package/src/lib/app-owned-memory-stores.ts +22 -0
  24. package/src/lib/tool-argument-integers.ts +158 -0
  25. package/src/oauth/nous.ts +58 -9
  26. package/src/providers/base-url-choices.ts +10 -0
  27. package/src/providers/command-code-efforts.ts +18 -0
  28. package/src/providers/model-rename-migration.ts +202 -0
  29. package/src/providers/model-rename-startup.ts +28 -0
  30. package/src/providers/openai-tier-startup.ts +31 -2
  31. package/src/providers/quota.ts +9 -2
  32. package/src/providers/registry.ts +12 -5
  33. package/src/responses/spill-store.ts +5 -1
  34. package/src/responses/state.ts +50 -2
  35. package/src/server/index.ts +2 -1
  36. package/src/server/management/api-key-usage.ts +31 -5
  37. package/src/server/management/logs-usage-routes.ts +48 -10
  38. package/src/server/management/provider-routes.ts +2 -1
  39. package/src/server/management/usage-summary-cache.ts +7 -1
  40. package/src/server/responses/collaboration.ts +12 -2
  41. package/src/server/responses/core.ts +33 -16
  42. package/src/server/startup-health-cache.ts +12 -0
  43. package/src/usage/log.ts +430 -12
  44. package/gui/dist/assets/index-DuaUVm_d.js +0 -76
package/src/usage/log.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { createHash, type Hash } from "node:crypto";
1
2
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { getConfigDir } from "../config";
5
+ import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
4
6
  import { recordOwnedConfigPath } from "../lib/config-ownership";
5
7
  import { usageDisplayTotalTokens } from "./totals";
6
8
  import type { OcxUsage } from "../types";
@@ -448,7 +450,16 @@ let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
448
450
  const MANAGEMENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
449
451
  const RECENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
450
452
  const MANAGEMENT_USAGE_READ_CHUNK_BYTES = 1024 * 1024;
451
- const MANAGEMENT_USAGE_MAX_ENTRIES = 500_000;
453
+ const MANAGEMENT_USAGE_MAX_ENTRIES_DEFAULT = 500_000;
454
+ /**
455
+ * Row cap for a management snapshot. Overridable only so tests can reach the cap without
456
+ * building a half-million-row fixture; production always uses the default.
457
+ */
458
+ let MANAGEMENT_USAGE_MAX_ENTRIES = MANAGEMENT_USAGE_MAX_ENTRIES_DEFAULT;
459
+
460
+ export function setManagementUsageMaxEntriesForTests(value: number | null): void {
461
+ MANAGEMENT_USAGE_MAX_ENTRIES = value ?? MANAGEMENT_USAGE_MAX_ENTRIES_DEFAULT;
462
+ }
452
463
  const MANAGEMENT_USAGE_FLIGHT_STALE_MS = 30_000;
453
464
  export interface ManagementUsageSnapshot {
454
465
  entries: PersistedUsageEntry[];
@@ -456,14 +467,156 @@ export interface ManagementUsageSnapshot {
456
467
  truncatedPrefixBytes: number;
457
468
  entriesTruncated: boolean;
458
469
  entriesDropped: number;
470
+ /** Digest of the covered prefix, used to detect an in-place rewrite before reuse. */
471
+ prefixDigest: string;
472
+ /**
473
+ * Byte offset where the RETAINED ROWS begin.
474
+ *
475
+ * Distinct from `truncatedPrefixBytes`, which is the API-visible "bytes skipped by the
476
+ * byte window" signal and must stay independent of entry-count truncation. When the
477
+ * entry cap drops rows, those bytes are not window truncation, but the retained rows do
478
+ * start later -- this field tracks that so the byte accounting stays exact.
479
+ */
480
+ rowsBeginAtBytes: number;
481
+ /**
482
+ * Byte length of each returned row, in order, including its newline.
483
+ *
484
+ * Lets a later read trim rows that have fallen out of the bounded window without
485
+ * re-reading or re-parsing them, which is what keeps the returned set equal to the
486
+ * window the caller asked for.
487
+ */
488
+ entryLengths: number[];
489
+ /** Unparseable bytes after the last returned row; the next read folds them forward. */
490
+ trailingSkippedBytes: number;
459
491
  }
460
492
  let managementUsageReadInflight: {
461
493
  key: string;
494
+ openedSize: number;
462
495
  promise: Promise<ManagementUsageSnapshot>;
463
496
  startedAt: number;
464
497
  abort: AbortController;
465
498
  } | null = null;
466
499
 
500
+ /**
501
+ * Append-tolerant snapshot of the last management read.
502
+ *
503
+ * The management reader parses a 64 MiB tail into ~53k objects, which costs roughly
504
+ * 640 MB of transient RSS per cold call. The JS objects are collected promptly, but
505
+ * the allocator does not return those pages, so every cold miss ratchets process RSS
506
+ * upward and never comes back down (observed: 7.9 GiB RSS against a 130 MB JS heap).
507
+ *
508
+ * Reparsing an unchanged prefix is what makes that transient recur. `usage.jsonl` is
509
+ * append-only under a stable identity, so when the file has only grown we keep the
510
+ * previously parsed rows and parse just the appended bytes. This is retained state, so
511
+ * it is registered with the app-owned memory budget and is evictable under pressure.
512
+ */
513
+ interface RetainedUsageSnapshot {
514
+ identityKey: string;
515
+ maxReadBytes: number;
516
+ /** Absolute end offset in the file that `entries` already covers. */
517
+ coveredThroughBytes: number;
518
+ /**
519
+ * Digest of the last bytes of the covered prefix, re-verified before extending.
520
+ *
521
+ * Identity (path/dev/ino/birthtime) intentionally ignores size and mtime so appends
522
+ * can share work, which also means an in-place rewrite that keeps the inode is
523
+ * invisible to it. A hand-edit or external compaction can therefore replace history
524
+ * under a stable identity without shrinking the file. Re-reading this trailing window
525
+ * catches that: if the bytes behind `coveredThroughBytes` changed, the retained rows
526
+ * no longer describe the file and must not be extended.
527
+ */
528
+ prefixDigest: string;
529
+ /** Bytes of the file skipped ahead of the retained window. */
530
+ truncatedPrefixBytes: number;
531
+ /** Byte length of each retained row, so out-of-window rows can be trimmed exactly. */
532
+ entryLengths: number[];
533
+ /** Unparseable bytes after the last retained row; folded into the next row's span. */
534
+ trailingSkippedBytes: number;
535
+ /** Byte offset where the retained rows begin; see ManagementUsageSnapshot. */
536
+ rowsBeginAtBytes: number;
537
+ entries: PersistedUsageEntry[];
538
+ entriesTruncated: boolean;
539
+ entriesDropped: number;
540
+ revision: UsageLogRevision;
541
+ retainedAt: number;
542
+ approxBytes: number;
543
+ }
544
+ let retainedUsageSnapshot: RetainedUsageSnapshot | null = null;
545
+
546
+ /** Rough per-row retained cost; exact sizing would cost another full serialization pass. */
547
+ const RETAINED_USAGE_ENTRY_BYTES = 512;
548
+
549
+ /** Chunk size used when digesting a retained region. */
550
+ const RETAINED_USAGE_DIGEST_CHUNK_BYTES = 1024 * 1024;
551
+
552
+ /**
553
+ * Digest a byte range into `hash`; false when it cannot be read.
554
+ *
555
+ * Deliberately not sampled. A sampled digest covers a vanishing fraction of a large
556
+ * prefix (32 KiB of 64 MiB is 0.05%), so an ordinary fixed-width in-place edit -- a
557
+ * redaction script fixing one field, a compaction rewriting a middle region -- lands in
558
+ * a gap by default and the stale rows are served.
559
+ *
560
+ * Hashing from byte 0 on every call is also wrong: that is O(file) per poll while the
561
+ * read it protects is capped at maxReadBytes, so the ratio degrades as the ledger grows
562
+ * and becomes SLOWER than a full read past roughly 1-2 GB. Only the retained REGION
563
+ * (truncatedPrefixBytes..coveredThroughBytes) is hashed. It is never wider than the
564
+ * window, and bytes below the retained start describe no retained row, so reading them
565
+ * would prove nothing.
566
+ */
567
+ function updateUsageDigest(hash: Hash, fd: number, from: number, to: number): boolean {
568
+ if (to <= from) return true;
569
+ const buffer = Buffer.allocUnsafe(Math.min(RETAINED_USAGE_DIGEST_CHUNK_BYTES, to - from));
570
+ for (let position = from; position < to;) {
571
+ const length = Math.min(buffer.byteLength, to - position);
572
+ let offset = 0;
573
+ while (offset < length) {
574
+ const read = readSync(fd, buffer, offset, length - offset, position + offset);
575
+ if (read === 0) return false;
576
+ offset += read;
577
+ }
578
+ hash.update(buffer.subarray(0, length));
579
+ position += length;
580
+ }
581
+ return true;
582
+ }
583
+
584
+ /**
585
+ * Digest of `from`..`to`; null when it cannot be read.
586
+ *
587
+ * The range is bound into the digest so a region cannot be confused with an equal-length
588
+ * region at a different offset.
589
+ */
590
+ function usageRegionDigest(fd: number, from: number, to: number): string | null {
591
+ if (to <= from) return `${from}:${to}:empty`;
592
+ const hash = createHash("sha256");
593
+ if (!updateUsageDigest(hash, fd, from, to)) return null;
594
+ return `${from}:${to}:${hash.digest("hex")}`;
595
+ }
596
+
597
+ function retainedUsageSnapshotBytes(entries: PersistedUsageEntry[]): number {
598
+ return entries.length * RETAINED_USAGE_ENTRY_BYTES;
599
+ }
600
+
601
+ export function discardRetainedUsageSnapshot(): number {
602
+ const released = retainedUsageSnapshot?.approxBytes ?? 0;
603
+ retainedUsageSnapshot = null;
604
+ return released;
605
+ }
606
+
607
+ export function retainedUsageSnapshotStats(): {
608
+ count: number;
609
+ bytes: number;
610
+ oldestAt: number | null;
611
+ } {
612
+ if (!retainedUsageSnapshot) return { count: 0, bytes: 0, oldestAt: null };
613
+ return {
614
+ count: 1,
615
+ bytes: retainedUsageSnapshot.approxBytes,
616
+ oldestAt: retainedUsageSnapshot.retainedAt,
617
+ };
618
+ }
619
+
467
620
  /** Test-only observability for proving that unchanged prefixes are not reparsed. */
468
621
  export function usageReadCacheStatsForTests(): Readonly<typeof usageReadCacheStats> {
469
622
  return { ...usageReadCacheStats };
@@ -473,6 +626,7 @@ export function resetUsageReadCacheForTests(): void {
473
626
  usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
474
627
  managementUsageReadInflight?.abort.abort();
475
628
  managementUsageReadInflight = null;
629
+ retainedUsageSnapshot = null;
476
630
  }
477
631
 
478
632
  function readExactly(fd: number, length: number, position: number): Buffer | null {
@@ -512,6 +666,12 @@ export function usageLogRevisionKey(revision: UsageLogRevision | null): string {
512
666
  ].join("\0");
513
667
  }
514
668
 
669
+ /** Identity of the usage ledger file, excluding size/mtime/ctime so appends can share work. */
670
+ export function usageLogIdentityKey(revision: UsageLogRevision | null): string {
671
+ if (!revision) return "missing";
672
+ return [revision.path, revision.dev, revision.ino, revision.birthtimeMs].join("\0");
673
+ }
674
+
515
675
  export function currentUsageLogRevision(): UsageLogRevision | null {
516
676
  const path = usageLogPath();
517
677
  if (!existsSync(path)) return null;
@@ -527,23 +687,71 @@ export function currentUsageLogRevision(): UsageLogRevision | null {
527
687
  async function parseUsageTextCooperatively(text: string, signal: AbortSignal): Promise<{
528
688
  entries: PersistedUsageEntry[];
529
689
  entriesDropped: number;
690
+ entryLengths: number[];
691
+ /** Bytes of unparseable lines after the final accepted row. */
692
+ trailingSkippedBytes: number;
693
+ /** Bytes of rows removed by the entry cap, which move into the skipped prefix. */
694
+ cappedPrefixBytes: number;
530
695
  }> {
531
- const lines = text.split(/\r?\n/);
696
+ // Split on "\n" only, so a CRLF line keeps its "\r" and its byte length stays exact.
697
+ // Splitting on /\r?\n/ consumes two bytes but leaves no way to tell that it did, which
698
+ // made the recorded lengths short by one byte per line on a CRLF ledger and failed the
699
+ // accounting self-check. JSON.parse tolerates the trailing "\r".
700
+ const lines = text.split("\n");
532
701
  usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length;
533
702
  const entries: PersistedUsageEntry[] = [];
703
+ // Byte length of each accepted row including its newline, so a later read can trim
704
+ // rows that fall out of the bounded window without re-reading the file.
705
+ const entryLengths: number[] = [];
534
706
  const batchSize = 1_000;
707
+ // Bytes of lines that did not yield an entry (malformed JSON, missing requestId, a
708
+ // torn final write). They still occupy space in the file, so they are folded into the
709
+ // next accepted row's recorded length. Dropping them would make the recorded lengths
710
+ // sum to less than the real byte span, and the window trim -- which walks forward by
711
+ // summing those lengths -- would consume extra rows to reach the window start,
712
+ // silently hiding history and desynchronizing truncatedPrefixBytes.
713
+ let pendingSkippedBytes = 0;
535
714
  for (let offset = 0; offset < lines.length; offset += batchSize) {
536
715
  if (signal.aborted) throw signal.reason;
537
- entries.push(...parseUsageLines(lines.slice(offset, offset + batchSize)));
716
+ const batch = lines.slice(offset, offset + batchSize);
717
+ for (let index = 0; index < batch.length; index++) {
718
+ const line = batch[index]!;
719
+ // The split leaves a trailing "" after the final newline; it occupies no bytes.
720
+ const isLastLine = offset + index === lines.length - 1;
721
+ const lineBytes = Buffer.byteLength(line, "utf-8") + (isLastLine && line === "" ? 0 : 1);
722
+ const parsed = parseUsageLines([line]);
723
+ if (parsed.length === 0) {
724
+ pendingSkippedBytes += lineBytes;
725
+ continue;
726
+ }
727
+ entries.push(parsed[0]!);
728
+ entryLengths.push(lineBytes + pendingSkippedBytes);
729
+ pendingSkippedBytes = 0;
730
+ }
538
731
  if (offset + batchSize < lines.length) {
539
732
  // JSON parsing dominates large-log startup. Yield between bounded batches so
540
733
  // Bun can continue serving health and settings requests on the same thread.
541
734
  await new Promise<void>(resolve => setTimeout(resolve, 0));
542
735
  }
543
736
  }
544
- if (entries.length <= MANAGEMENT_USAGE_MAX_ENTRIES) return { entries, entriesDropped: 0 };
737
+ // Skipped bytes AFTER the last accepted row belong to no entry length, so report them
738
+ // separately; the trim arithmetic adds them back to keep lengths summing to the span.
739
+ const trailingSkippedBytes = pendingSkippedBytes;
740
+ if (entries.length <= MANAGEMENT_USAGE_MAX_ENTRIES) {
741
+ return { entries, entriesDropped: 0, entryLengths, trailingSkippedBytes, cappedPrefixBytes: 0 };
742
+ }
545
743
  const entriesDropped = entries.length - MANAGEMENT_USAGE_MAX_ENTRIES;
546
- return { entries: entries.slice(-MANAGEMENT_USAGE_MAX_ENTRIES), entriesDropped };
744
+ return {
745
+ entries: entries.slice(-MANAGEMENT_USAGE_MAX_ENTRIES),
746
+ entriesDropped,
747
+ entryLengths: entryLengths.slice(-MANAGEMENT_USAGE_MAX_ENTRIES),
748
+ trailingSkippedBytes,
749
+ // Bytes of the rows the cap removed. The caller adds them to its skipped prefix so
750
+ // the recorded lengths keep summing to the byte span they describe.
751
+ cappedPrefixBytes: entryLengths
752
+ .slice(0, entryLengths.length - MANAGEMENT_USAGE_MAX_ENTRIES)
753
+ .reduce((total, length) => total + length, 0),
754
+ };
547
755
  }
548
756
 
549
757
  async function readUsageEntriesFullCooperatively(
@@ -584,12 +792,181 @@ async function readUsageEntriesFullCooperatively(
584
792
  }
585
793
  const parsed = await parseUsageTextCooperatively(bytes.toString("utf-8"), signal);
586
794
  usageReadCacheStats.fullReads += 1;
795
+ // Rows removed by the entry cap start the retained rows later in the file. That is
796
+ // NOT byte-window truncation, so it must not move truncatedPrefixBytes -- the two
797
+ // signals are independent in the API. It is tracked separately for the byte
798
+ // accounting the incremental reader relies on.
799
+ const rowsBeginAtBytes = truncatedPrefixBytes + parsed.cappedPrefixBytes;
800
+ // Digest the exact prefix these rows describe, so a later incremental read can
801
+ // prove the file was appended to rather than rewritten under the same inode.
802
+ const prefixDigest = usageRegionDigest(fd, rowsBeginAtBytes, Number(stat.size));
803
+ if (prefixDigest === null) throw new Error("usage log changed while it was being read");
587
804
  return {
588
805
  entries: parsed.entries,
589
806
  revision: usageLogRevision(path, stat),
590
807
  truncatedPrefixBytes,
591
808
  entriesTruncated: parsed.entriesDropped > 0,
592
809
  entriesDropped: parsed.entriesDropped,
810
+ prefixDigest,
811
+ entryLengths: parsed.entryLengths,
812
+ trailingSkippedBytes: parsed.trailingSkippedBytes,
813
+ rowsBeginAtBytes,
814
+ };
815
+ } finally {
816
+ if (fd !== undefined) closeSync(fd);
817
+ }
818
+ }
819
+
820
+ /**
821
+ * Parse only the bytes appended since the retained snapshot's covered offset.
822
+ *
823
+ * Returns null when the retained snapshot cannot be extended safely — a different
824
+ * identity or read window, a file that shrank (replacement/truncation), or a covered
825
+ * offset that no longer sits on a record boundary. Callers then fall back to a full
826
+ * bounded read.
827
+ */
828
+ async function readUsageEntriesIncrementally(
829
+ path: string,
830
+ signal: AbortSignal,
831
+ maxReadBytes: number,
832
+ retained: RetainedUsageSnapshot,
833
+ ): Promise<ManagementUsageSnapshot | null> {
834
+ let fd: number | undefined;
835
+ try {
836
+ fd = openSync(path, "r");
837
+ const stat = fstatSync(fd);
838
+ const revision = usageLogRevision(path, stat);
839
+ if (usageLogIdentityKey(revision) !== retained.identityKey) return null;
840
+ const size = Number(stat.size);
841
+ // A shrink means truncation or replacement-in-place; the retained rows may no
842
+ // longer correspond to file contents, so refuse to extend them.
843
+ if (size < retained.coveredThroughBytes) return null;
844
+ // Verify the retained REGION is unchanged before anything is reused. Identity keeps
845
+ // dev/ino/birthtime, and an append and an in-place rewrite both move mtime/ctime
846
+ // forward, so only the bytes themselves settle it.
847
+ //
848
+ // Only `truncatedPrefixBytes..coveredThroughBytes` is hashed: that is exactly the
849
+ // span the retained rows were parsed from, and after trimming it is never wider than
850
+ // maxReadBytes. Hashing from byte 0 instead would make every poll O(file) -- cheaper
851
+ // than a reparse on a 245 MB ledger but MORE expensive past roughly 1-2 GB, turning
852
+ // this optimization into a pessimization on exactly the growth curve an append-only
853
+ // ledger follows. Bytes before the retained start are not described by any retained
854
+ // row, so re-reading them proves nothing.
855
+ const covered = usageRegionDigest(fd, retained.rowsBeginAtBytes, retained.coveredThroughBytes);
856
+ if (covered === null || covered !== retained.prefixDigest) return null;
857
+ // Read and parse ONLY the appended bytes.
858
+ let appendedEntries: PersistedUsageEntry[] = [];
859
+ let appendedLengths: number[] = [];
860
+ let appendedDropped = 0;
861
+ let appendedTrailingSkipped = retained.trailingSkippedBytes;
862
+ if (size > retained.coveredThroughBytes) {
863
+ // The covered offset must land immediately after a newline, or the retained rows
864
+ // and the appended text do not join on a record boundary.
865
+ if (retained.coveredThroughBytes > 0) {
866
+ const preceding = readExactly(fd, 1, retained.coveredThroughBytes - 1);
867
+ if (preceding === null || preceding[0] !== 0x0a) return null;
868
+ }
869
+ const chunks: Buffer[] = [];
870
+ for (let position = retained.coveredThroughBytes; position < size;) {
871
+ if (signal.aborted) throw signal.reason;
872
+ const length = Math.min(MANAGEMENT_USAGE_READ_CHUNK_BYTES, size - position);
873
+ const chunk = readExactly(fd, length, position);
874
+ if (chunk === null) throw new Error("usage log changed while it was being read");
875
+ chunks.push(chunk);
876
+ position += length;
877
+ }
878
+ const appended = await parseUsageTextCooperatively(Buffer.concat(chunks).toString("utf-8"), signal);
879
+ appendedEntries = appended.entries;
880
+ appendedLengths = appended.entryLengths;
881
+ appendedDropped = appended.entriesDropped;
882
+ // A capped appended chunk is not joinable: its dropped rows sit between the
883
+ // retained rows and the kept ones, so the lengths no longer describe a contiguous
884
+ // span. Fall back to a full read.
885
+ if (appended.cappedPrefixBytes > 0) return null;
886
+ // If the appended chunk produced rows, its own trailing skipped bytes become the
887
+ // new trailing remainder; otherwise the earlier remainder still stands and the new
888
+ // skipped bytes add to it.
889
+ appendedTrailingSkipped = appended.entries.length > 0
890
+ ? appended.trailingSkippedBytes
891
+ : retained.trailingSkippedBytes + appended.trailingSkippedBytes;
892
+ }
893
+ // Re-anchor the window in place. Rows that have fallen outside `size - maxReadBytes`
894
+ // are dropped using their recorded byte lengths, so the result is exactly the rows a
895
+ // fresh bounded read would load -- no superset, and truncatedPrefixBytes and
896
+ // snapshotWindow keep describing the read honestly. Refusing here instead would make
897
+ // this path dead code on any ledger past the window, which is precisely the case it
898
+ // exists for.
899
+ const windowStart = Math.max(0, size - maxReadBytes);
900
+ let entries = retained.entries.concat(appendedEntries);
901
+ // Skipped bytes trailing the retained rows sit BETWEEN them and the appended rows, so
902
+ // they belong to the first appended row's span. Folding them in keeps the recorded
903
+ // lengths summing to the true byte distance, which is what the trim walk relies on.
904
+ const joinedLengths = appendedLengths.slice();
905
+ if (retained.trailingSkippedBytes > 0 && joinedLengths.length > 0) {
906
+ joinedLengths[0] = joinedLengths[0]! + retained.trailingSkippedBytes;
907
+ }
908
+ let lengths = retained.entryLengths.concat(joinedLengths);
909
+ let rowsBeginAtBytes = retained.rowsBeginAtBytes;
910
+ // Byte-window truncation advances ONLY here, so it stays exactly what a cold read of
911
+ // this window reports. The entry cap below is entry-count truncation and must not
912
+ // move it -- the two are independent signals in the API.
913
+ let windowTruncatedBytes = retained.truncatedPrefixBytes;
914
+ let dropIndex = 0;
915
+ while (dropIndex < lengths.length && rowsBeginAtBytes < windowStart) {
916
+ rowsBeginAtBytes += lengths[dropIndex]!;
917
+ windowTruncatedBytes += lengths[dropIndex]!;
918
+ dropIndex += 1;
919
+ }
920
+ // If every row is gone and a trailing unparseable remainder still sits before the
921
+ // window start, nothing is left to advance the offset with: the retained span would
922
+ // keep growing past maxReadBytes on each malformed-only append while the accounting
923
+ // still balanced. Re-anchor with a full read instead.
924
+ if (rowsBeginAtBytes < windowStart) return null;
925
+ if (dropIndex > 0) {
926
+ entries = entries.slice(dropIndex);
927
+ lengths = lengths.slice(dropIndex);
928
+ }
929
+ let entriesDropped = retained.entriesDropped + appendedDropped;
930
+ if (entries.length > MANAGEMENT_USAGE_MAX_ENTRIES) {
931
+ // A cold read applies the entry cap to the whole window and reports byte
932
+ // truncation for the window boundary alone. An incremental read arrives at the cap
933
+ // by a different route and cannot reconstruct that ordering from retained state, so
934
+ // continuing here would report a truncatedPrefixBytes that disagrees with a fresh
935
+ // read of the same window. Re-anchor instead.
936
+ //
937
+ // This is reachable in production, not a theoretical branch: rows average ~118
938
+ // bytes on a real ledger, so 500,000 of them occupy ~56 MiB and fit inside the
939
+ // 64 MiB window. Both truncations can therefore apply at once.
940
+ return null;
941
+ }
942
+ // The recorded lengths plus the trailing remainder must account for every byte from
943
+ // the retained rows' start to EOF; if they do not, the lengths and the file have
944
+ // diverged and the retained rows cannot be trusted.
945
+ let accounted = appendedTrailingSkipped;
946
+ for (const length of lengths) accounted += length;
947
+ if (rowsBeginAtBytes + accounted !== size) return null;
948
+ usageReadCacheStats.tailReads += 1;
949
+ // Byte-window truncation is what the API reports, and it stays independent of
950
+ // entry-count truncation. A cold read reports the record boundary it actually landed
951
+ // on, which is where the rows begin MINUS whatever the entry cap removed -- the cap
952
+ // is not window truncation. When nothing was skipped by the window at all, a cold
953
+ // read reports 0.
954
+ const truncatedPrefixBytes = windowTruncatedBytes;
955
+ return {
956
+ entries,
957
+ revision,
958
+ truncatedPrefixBytes,
959
+ // ENTRY-count truncation only. Byte-window truncation is reported by
960
+ // truncatedPrefixBytes, and the route ORs the two itself; folding bytes in here
961
+ // would make a byte-truncated read claim rows were dropped when none were.
962
+ entriesTruncated: entriesDropped > 0,
963
+ entriesDropped,
964
+ // The digest must describe exactly the region the returned rows came from, which
965
+ // is the post-trim window, not the pre-trim one.
966
+ prefixDigest: usageRegionDigest(fd, rowsBeginAtBytes, size) ?? "",
967
+ entryLengths: lengths,
968
+ trailingSkippedBytes: appendedTrailingSkipped,
969
+ rowsBeginAtBytes,
593
970
  };
594
971
  } finally {
595
972
  if (fd !== undefined) closeSync(fd);
@@ -598,8 +975,10 @@ async function readUsageEntriesFullCooperatively(
598
975
 
599
976
  /**
600
977
  * Management API reader: full parses yield between bounded batches and concurrent
601
- * callers share work only when they observed the same exact file revision. Parsed rows
602
- * are returned to the request and never retained in module state.
978
+ * callers share work when they observe the same ledger identity and byte window.
979
+ * Appends keep that identity; replacements (inode/birthtime change) start a new flight.
980
+ * The parsed tail is retained under the app-owned memory budget so an append reparses
981
+ * only the appended bytes; the retained rows are evictable and are copied per caller.
603
982
  */
604
983
  export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_USAGE_MAX_READ_BYTES): Promise<{
605
984
  entries: PersistedUsageEntry[];
@@ -612,18 +991,57 @@ export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_U
612
991
  const path = usageLogPath();
613
992
  if (!existsSync(path)) return { entries: [], revision: null, truncatedPrefixBytes: 0, entriesTruncated: false, entriesDropped: 0 };
614
993
  const observed = currentUsageLogRevision();
615
- const key = `${usageLogRevisionKey(observed)}\0${maxReadBytes}`;
994
+ const key = `${usageLogIdentityKey(observed)}\0${maxReadBytes}`;
995
+ const observedSize = observed?.size ?? 0;
616
996
  const existing = managementUsageReadInflight;
617
- if (existing?.key === key && Date.now() - existing.startedAt <= MANAGEMENT_USAGE_FLIGHT_STALE_MS) {
997
+ const replacement = Boolean(existing && observedSize < existing.openedSize);
998
+ if (!replacement && existing?.key === key && Date.now() - existing.startedAt <= MANAGEMENT_USAGE_FLIGHT_STALE_MS) {
999
+ const shared = await existing.promise;
1000
+ return { ...shared, entries: shared.entries.slice() };
1001
+ }
1002
+ if (existing && (existing.key !== key || replacement || Date.now() - existing.startedAt > MANAGEMENT_USAGE_FLIGHT_STALE_MS)) {
1003
+ existing.abort.abort(new Error("management usage read superseded"));
1004
+ } else if (existing) {
618
1005
  const shared = await existing.promise;
619
1006
  return { ...shared, entries: shared.entries.slice() };
620
1007
  }
621
- existing?.abort.abort(new Error("management usage read superseded"));
622
1008
  const abort = new AbortController();
623
- const promise = readUsageEntriesFullCooperatively(path, abort.signal, maxReadBytes);
624
- managementUsageReadInflight = { key, promise, startedAt: Date.now(), abort };
1009
+ const retained = retainedUsageSnapshot;
1010
+ const reusable = retained
1011
+ && retained.identityKey === usageLogIdentityKey(observed)
1012
+ && retained.maxReadBytes === maxReadBytes
1013
+ ? retained
1014
+ : null;
1015
+ const promise = (async (): Promise<ManagementUsageSnapshot> => {
1016
+ if (reusable) {
1017
+ const incremental = await readUsageEntriesIncrementally(path, abort.signal, maxReadBytes, reusable);
1018
+ if (incremental) return incremental;
1019
+ // The retained rows could not be extended safely; drop them before the full read
1020
+ // so a stale window is never combined with freshly parsed bytes.
1021
+ discardRetainedUsageSnapshot();
1022
+ }
1023
+ return readUsageEntriesFullCooperatively(path, abort.signal, maxReadBytes);
1024
+ })();
1025
+ managementUsageReadInflight = { key, openedSize: observedSize, promise, startedAt: Date.now(), abort };
625
1026
  try {
626
1027
  const snapshot = await promise;
1028
+ retainedUsageSnapshot = {
1029
+ identityKey: usageLogIdentityKey(snapshot.revision),
1030
+ maxReadBytes,
1031
+ coveredThroughBytes: snapshot.revision.size,
1032
+ prefixDigest: snapshot.prefixDigest,
1033
+ truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
1034
+ entryLengths: snapshot.entryLengths,
1035
+ trailingSkippedBytes: snapshot.trailingSkippedBytes,
1036
+ rowsBeginAtBytes: snapshot.rowsBeginAtBytes,
1037
+ entries: snapshot.entries,
1038
+ entriesTruncated: snapshot.entriesTruncated,
1039
+ entriesDropped: snapshot.entriesDropped,
1040
+ revision: snapshot.revision,
1041
+ retainedAt: Date.now(),
1042
+ approxBytes: retainedUsageSnapshotBytes(snapshot.entries),
1043
+ };
1044
+ enforceAppOwnedMemoryBudget();
627
1045
  return { ...snapshot, entries: snapshot.entries.slice() };
628
1046
  } finally {
629
1047
  if (managementUsageReadInflight?.promise === promise) managementUsageReadInflight = null;