@genee/omp-opsx-addon 0.3.0 → 0.4.0

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.
@@ -299,8 +299,12 @@ function seedUnconfirmedFromDisk(): void {
299
299
  }
300
300
  }
301
301
 
302
- /** Read the realtime cache file, tolerating missing/partial fields. */
303
- function readRealtimeDisk(): RealtimeFile | null {
302
+ /**
303
+ * Parse the realtime cache file, tolerating missing/partial fields. RAW read:
304
+ * shape normalization (legacy per-session → global) only — no provider-id
305
+ * key canonicalization; callers decide when to canonicalize.
306
+ */
307
+ function parseRealtimeDisk(): RealtimeFile | null {
304
308
  try {
305
309
  const file = resolveRealtimeFile();
306
310
  if (!existsSync(file)) return null;
@@ -320,8 +324,20 @@ function readRealtimeDisk(): RealtimeFile | null {
320
324
  }
321
325
  }
322
326
 
323
- /** Read the fetch cache file, tolerating missing/partial fields. */
324
- function readFetchDisk(): FetchFile | null {
327
+ /**
328
+ * Read the realtime cache file, tolerating missing/partial fields. Old
329
+ * provider ids (`ArkCodingPlan` / `arkcodingplan`) are canonicalized into
330
+ * `ark-coding-plan` before returning, so every consumer (render, dirty
331
+ * signal, locked delta re-read) sees a single canonical column.
332
+ */
333
+ function readRealtimeDisk(): RealtimeFile | null {
334
+ const disk = parseRealtimeDisk();
335
+ if (disk) canonicalizeRealtime(disk);
336
+ return disk;
337
+ }
338
+
339
+ /** Parse the fetch cache file, tolerating missing/partial fields. RAW read: no provider-id canonicalization. */
340
+ function parseFetchDisk(): FetchFile | null {
325
341
  try {
326
342
  const file = resolveFetchFile();
327
343
  if (!existsSync(file)) return null;
@@ -336,6 +352,122 @@ function readFetchDisk(): FetchFile | null {
336
352
  }
337
353
  }
338
354
 
355
+ /** Read the fetch cache file, tolerating missing/partial fields. Old provider ids are canonicalized. */
356
+ function readFetchDisk(): FetchFile | null {
357
+ const fetch = parseFetchDisk();
358
+ if (fetch) canonicalizeFetch(fetch);
359
+ return fetch;
360
+ }
361
+
362
+ // ── provider-id canonicalization (legacy → canonical) ──────────────
363
+ // The old npm package wrote `arkcodingplan` (mixed-case `ArkCodingPlan` in
364
+ // models.yml); the runtime canonical id is `ark-coding-plan`. Every read path
365
+ // normalizes disk keys onto the canonical id so a provider never surfaces as
366
+ // two columns, and the locked write-back helpers converge the file itself.
367
+ // All helpers are pure + idempotent: once an old key is merged and deleted,
368
+ // the canonical key already holds the accumulated value, so a repeat pass
369
+ // changes nothing and reports `changed === false`.
370
+
371
+ /** Merge `totals` keys whose canonical form differs into that canonical key (bucket fields SUM), deleting the old keys. Returns whether anything changed. */
372
+ function canonicalizeTotalsKeys(totals: Record<string, UsageBucket>): boolean {
373
+ let changed = false;
374
+ for (const key of Object.keys(totals)) {
375
+ const canonical = canonicalizeProvider(key);
376
+ if (canonical === key) continue;
377
+ changed = true;
378
+ const prev = normalizeBucket(totals[canonical]);
379
+ const src = normalizeBucket(totals[key]);
380
+ totals[canonical] = {
381
+ total: prev.total + src.total,
382
+ input: prev.input + src.input,
383
+ output: prev.output + src.output,
384
+ };
385
+ delete totals[key];
386
+ }
387
+ return changed;
388
+ }
389
+
390
+ /**
391
+ * Merge per-second provider keys onto their canonical form (disk shape
392
+ * `{sec: {provider: tokens}}`), summing tokens within the same second and
393
+ * deleting the old keys. Called BEFORE `transposeSeconds`, so
394
+ * `readRealtimeDisk` and `getSessionUsagePerSecond` share the same shape.
395
+ * Returns whether anything changed.
396
+ */
397
+ function canonicalizeSecondsKeys(seconds: Record<string, Record<string, number>>): boolean {
398
+ let changed = false;
399
+ for (const sec of Object.keys(seconds)) {
400
+ const secMap = seconds[sec];
401
+ for (const key of Object.keys(secMap)) {
402
+ const canonical = canonicalizeProvider(key);
403
+ if (canonical === key) continue;
404
+ changed = true;
405
+ secMap[canonical] = (secMap[canonical] ?? 0) + secMap[key];
406
+ delete secMap[key];
407
+ }
408
+ }
409
+ return changed;
410
+ }
411
+
412
+ /**
413
+ * Merge `fetchedByProvider` keys onto their canonical form, keeping the
414
+ * NEWEST timestamp (freshness signal — max, never summed). Returns whether
415
+ * anything changed.
416
+ */
417
+ function canonicalizeFetchedByProviderKeys(map: Record<string, number>): boolean {
418
+ let changed = false;
419
+ for (const key of Object.keys(map)) {
420
+ const canonical = canonicalizeProvider(key);
421
+ if (canonical === key) continue;
422
+ changed = true;
423
+ map[canonical] = Math.max(map[canonical] ?? 0, map[key]);
424
+ delete map[key];
425
+ }
426
+ return changed;
427
+ }
428
+
429
+ /**
430
+ * Rewrite `reports[].provider` onto its canonical form. When several reports
431
+ * collapse onto the same canonical provider (old-package + new-runtime
432
+ * copies), keep the LAST one — matching `mergeReports`' incoming-wins
433
+ * semantics (reports are never accumulated across providers). Returns whether
434
+ * anything changed.
435
+ */
436
+ function canonicalizeReports(reports: UsageReport[]): boolean {
437
+ let changed = false;
438
+ for (const r of reports) {
439
+ const canonical = canonicalizeProvider(r.provider);
440
+ if (canonical !== r.provider) {
441
+ r.provider = canonical;
442
+ changed = true;
443
+ }
444
+ }
445
+ const seen = new Set<string>();
446
+ for (let i = reports.length - 1; i >= 0; i--) {
447
+ if (!seen.has(reports[i].provider)) {
448
+ seen.add(reports[i].provider);
449
+ } else {
450
+ reports.splice(i, 1);
451
+ changed = true;
452
+ }
453
+ }
454
+ return changed;
455
+ }
456
+
457
+ /** Canonicalize a whole realtime disk object in place; returns whether anything changed. */
458
+ function canonicalizeRealtime(disk: RealtimeFile): boolean {
459
+ let changed = canonicalizeTotalsKeys(disk.totals ?? {});
460
+ changed = canonicalizeSecondsKeys(disk.seconds ?? {}) || changed;
461
+ return changed;
462
+ }
463
+
464
+ /** Canonicalize a whole fetch disk object in place; returns whether anything changed. */
465
+ function canonicalizeFetch(fetch: FetchFile): boolean {
466
+ let changed = canonicalizeFetchedByProviderKeys(fetch.fetchedByProvider ?? {});
467
+ changed = canonicalizeReports(fetch.reports) || changed;
468
+ return changed;
469
+ }
470
+
339
471
  /**
340
472
  * Per-session snapshot of what THIS process last wrote to the realtime file —
341
473
  * the delta baseline. On flush we add `current - lastFlushed` to the file's
@@ -438,6 +570,57 @@ function writeFetchPayload(fetch: {
438
570
  return writePayloadLocked(resolveFetchFile(), () => JSON.stringify(fetch, null, 2));
439
571
  }
440
572
 
573
+ /**
574
+ * Locked atomic replace of `file` with the payload `payload()` produces —
575
+ * unless `payload()` returns null, in which case NOTHING is written (no
576
+ * tmp+rename) and the lock is simply released. Returns true when a write
577
+ * landed, false when the payload was null or the write failed. `payload()`
578
+ * runs INSIDE the lock, so the write/no-write decision can be based on a
579
+ * lock-consistent disk re-read (read-modify-write).
580
+ */
581
+ function writePayloadLockedIfChanged(file: string, payload: () => string | null): boolean {
582
+ const release = acquireWriteLock(file);
583
+ try {
584
+ const body = payload();
585
+ if (body === null) return false;
586
+ ensureDir(dirname(file));
587
+ const tmp = `${file}.tmp-${process.pid}-${writeSeq++}`;
588
+ writeFileSync(tmp, body, 'utf-8');
589
+ renameSync(tmp, file);
590
+ return true;
591
+ } catch {
592
+ return false;
593
+ } finally {
594
+ release();
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Convergence write for the REALTIME file: under the lock, re-read the file,
600
+ * canonicalize old provider ids, and write back ONLY while a legacy key is
601
+ * still present. Once converged the canonical keys are unchanged → payload
602
+ * returns null → no write (no per-tick write amplification). The in-lock
603
+ * re-read means the write reflects the LATEST disk state, so a concurrent
604
+ * session's incremental write (serialized by the same lock) is never
605
+ * overwritten with a stale snapshot.
606
+ */
607
+ function writeRealtimeCanonicalized(): boolean {
608
+ return writePayloadLockedIfChanged(resolveRealtimeFile(), () => {
609
+ const disk = parseRealtimeDisk();
610
+ if (!disk) return null;
611
+ return canonicalizeRealtime(disk) ? JSON.stringify(disk, null, 2) : null;
612
+ });
613
+ }
614
+
615
+ /** Convergence write for the FETCH file — see {@link writeRealtimeCanonicalized}. */
616
+ function writeFetchCanonicalized(): boolean {
617
+ return writePayloadLockedIfChanged(resolveFetchFile(), () => {
618
+ const fetch = parseFetchDisk();
619
+ if (!fetch) return null;
620
+ return canonicalizeFetch(fetch) ? JSON.stringify(fetch, null, 2) : null;
621
+ });
622
+ }
623
+
441
624
  /**
442
625
  * Realtime write path: under the lock, re-read the file (the LATEST global
443
626
  * aggregates), add this process's per-provider deltas onto the corresponding
@@ -496,9 +679,13 @@ function aggregateLegacySessionUsage(
496
679
  const disk: RealtimeFile = { totals: {}, seconds: {} };
497
680
  for (const entry of Object.values(sessionUsage ?? {})) {
498
681
  for (const [provider, raw] of Object.entries(entry?.perProvider ?? {})) {
682
+ // Old npm-package ids (`arkcodingplan`) canonicalize here so the
683
+ // one-time migration never lands legacy keys on disk (this
684
+ // aggregation is written directly by `migrateLegacyCache`).
685
+ const canonical = canonicalizeProvider(provider);
499
686
  const bucket = normalizeBucket(raw);
500
- const prev = normalizeBucket(disk.totals[provider]);
501
- disk.totals[provider] = {
687
+ const prev = normalizeBucket(disk.totals[canonical]);
688
+ disk.totals[canonical] = {
502
689
  total: prev.total + bucket.total,
503
690
  input: prev.input + bucket.input,
504
691
  output: prev.output + bucket.output,
@@ -506,7 +693,7 @@ function aggregateLegacySessionUsage(
506
693
  const seconds = typeof raw === 'object' && raw !== null ? (raw as ProviderUsageTotals).seconds : undefined;
507
694
  for (const [sec, n] of Object.entries(seconds ?? {})) {
508
695
  const secMap = (disk.seconds[sec] ??= {});
509
- secMap[provider] = (secMap[provider] ?? 0) + n;
696
+ secMap[canonical] = (secMap[canonical] ?? 0) + n;
510
697
  }
511
698
  }
512
699
  }
@@ -555,6 +742,14 @@ function migrateLegacyCache(): void {
555
742
  * global `fetchedAt` when the cache file predates per-provider tracking.
556
743
  */
557
744
  function syncFromDisk(loggedInProviders?: ReadonlySet<string>): void {
745
+ // Convergence: normalize legacy provider ids on disk. Under the lock we
746
+ // re-read each file and write back ONLY while an old key is still present
747
+ // (idempotent — once converged, later ticks write nothing). The in-lock
748
+ // re-read guarantees the write reflects the latest disk state, so a
749
+ // concurrent session's locked incremental write is never overwritten by a
750
+ // stale snapshot.
751
+ writeRealtimeCanonicalized();
752
+ writeFetchCanonicalized();
558
753
  // Consumption signal (→ markDirty) reads the REALTIME file's GLOBAL totals:
559
754
  // any provider whose aggregate grew since the last sync marks dirty.
560
755
  const realtime = readRealtimeDisk();
@@ -1064,6 +1259,9 @@ export function getSessionUsageTotals(): Record<string, UsageBucket> {
1064
1259
  return usageReadCache.totals;
1065
1260
  }
1066
1261
  const disk = JSON.parse(readFileSync(file, 'utf-8')) as RealtimeFile;
1262
+ // Old provider ids merge into `ark-coding-plan` before caching, so the
1263
+ // consumption waveform (index.ts render source) never sees legacy keys.
1264
+ canonicalizeTotalsKeys(disk.totals ?? {});
1067
1265
  const totals: Record<string, UsageBucket> = {};
1068
1266
  for (const [p, raw] of Object.entries(disk.totals ?? {})) totals[p] = normalizeBucket(raw);
1069
1267
  usageReadCache = { ino: st.ino, mtimeMs: st.mtimeMs, size: st.size, totals };
@@ -1097,6 +1295,9 @@ export function getSessionUsagePerSecond(windowSecs = 60): Record<string, Record
1097
1295
  return filterSeconds(perSecondReadCache.perSecond, cutoff);
1098
1296
  }
1099
1297
  const disk = JSON.parse(readFileSync(file, 'utf-8')) as RealtimeFile;
1298
+ // Canonicalize on the DISK shape ({sec: {provider}}), before
1299
+ // transpose — same helper and shape as `readRealtimeDisk`.
1300
+ canonicalizeSecondsKeys((disk.seconds ?? {}) as Record<string, Record<string, number>>);
1100
1301
  const perSecond = transposeSeconds((disk.seconds ?? {}) as Record<string, Record<string, number>>, cutoff);
1101
1302
  perSecondReadCache = { ino: st.ino, mtimeMs: st.mtimeMs, size: st.size, perSecond };
1102
1303
  return perSecond;
@@ -32,7 +32,11 @@ _setRealtimeFileForTest(realtimeFile);
32
32
  _setFetchFileForTest(join(dirname(realtimeFile), 'fetch.json'));
33
33
  _setLegacyCacheFileForTest(join(dirname(realtimeFile), 'legacy.json'));
34
34
  const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
35
- const provider = `provider-${sessionId}`;
35
+ // Canonical (lowercase) provider id: the poller's read path normalizes every
36
+ // disk key through `canonicalizeProvider`, so a mixed-case id would be folded
37
+ // to lowercase in the file while the raw-case id flowed to the Redis mirrors —
38
+ // breaking the file==redis consistency this test asserts.
39
+ const provider = `provider-${sessionId.toLowerCase()}`;
36
40
  // Frozen seconds in the recent past: reconcile covers them while the worker is
37
41
  // alive; the test asserts file == redis on exactly these keys.
38
42
  const baseSec = Math.floor(Date.now() / 1000) - 10;