@genee/omp-opsx-addon 0.3.0 → 0.5.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.
- package/README.md +82 -0
- package/index.ts +248 -154
- package/lib/agent-defs.ts +4 -4
- package/lib/edit-variant-pin.ts +4 -4
- package/lib/logger.ts +33 -0
- package/lib/model-roles.ts +270 -0
- package/lib/pipe-core.ts +784 -0
- package/lib/pipe-push.ts +467 -0
- package/lib/reachability-cache.ts +2 -1
- package/lib/reachability-probe.ts +3 -2
- package/lib/status-probe.ts +4 -3
- package/lib/unified-config.ts +42 -1
- package/lib/usage-poller.ts +208 -7
- package/lib/usage-redis-multiproc.worker.ts +5 -1
- package/lib/usage-render.ts +151 -97
- package/lib/usage-resolver.ts +10 -4
- package/package.json +1 -1
package/lib/usage-poller.ts
CHANGED
|
@@ -299,8 +299,12 @@ function seedUnconfirmedFromDisk(): void {
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
-
/**
|
|
303
|
-
|
|
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
|
-
/**
|
|
324
|
-
|
|
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[
|
|
501
|
-
disk.totals[
|
|
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[
|
|
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
|
-
|
|
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;
|