@ipv9/tokentracker-cli 0.39.43 → 0.39.45
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 +18 -10
- package/dashboard/dist/assets/{Card-LPizs_gs.js → Card-2-TQg7P7.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
- package/dashboard/dist/assets/{FadeIn-B8aDegoD.js → FadeIn-BOc6XtOK.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-Vo2ZZXov.js → IpCheckPage-C2tIb68P.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-C5-Q9Q30.js → LimitsPage-BsuLQ9co.js} +1 -1
- package/dashboard/dist/assets/LocalOnlyNotice-C8R9KLef.js +1 -0
- package/dashboard/dist/assets/{PopoverPopup-CJf61ahu.js → PopoverPopup-BpfseoI7.js} +1 -1
- package/dashboard/dist/assets/{Select-BLGoaqgw.js → Select-Ctk8zze5.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-Bt02Fgwf.js → SelectItemText-BKZlFyFs.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-BnUJew-8.js → SettingsPage-CQXM8qGU.js} +1 -1
- package/dashboard/dist/assets/{SkillsPage-ImHg3Puy.js → SkillsPage-4EMm0eBX.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-qscVE2nO.js → WidgetsPage-C2sdX5g6.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-qM_7aClE.js → WrappedPage-CLiuEcQZ.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-CByq3BPT.js → arrow-up-right-BDkp93DX.js} +1 -1
- package/dashboard/dist/assets/{download-CTwO-YeA.js → download-DZ6SoCSn.js} +1 -1
- package/dashboard/dist/assets/{format-4chvNBjF.js → format-CaW9kvsA.js} +1 -1
- package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
- package/dashboard/dist/assets/{main-CCPcJ7ti.js → main-DgyymGht.js} +16 -3
- package/dashboard/dist/assets/main-t7dbBL4x.css +1 -0
- package/dashboard/dist/assets/{mock-data-DSiJ-9lr.js → mock-data-D6C7Fba3.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-Dgd-bQBC.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
- package/dashboard/dist/assets/{use-native-settings-CjZRLdFT.js → use-native-settings-BKAzGuxw.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-BJRU0syn.js → useCurrency-BVr6Ajuu.js} +1 -1
- package/dashboard/dist/index.html +2 -2
- package/package.json +5 -3
- package/src/commands/doctor.js +8 -0
- package/src/commands/init.js +1 -1
- package/src/commands/sync.js +67 -0
- package/src/lib/doctor.js +227 -1
- package/src/lib/local-api.js +385 -113
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/process-list.js +91 -0
- package/src/lib/queue-compact.js +220 -0
- package/src/lib/rollout.js +81 -14
- package/src/lib/single-flight.js +59 -0
- package/src/lib/skills-manager.js +2 -2
- package/src/lib/transcript-suppression.js +133 -0
- package/src/lib/usage-limits.js +99 -22
- package/dashboard/dist/assets/DashboardPage-CkhqD3x3.js +0 -60
- package/dashboard/dist/assets/LocalOnlyNotice-DXVmRcyV.js +0 -1
- package/dashboard/dist/assets/limitDisplay-CXlkWhjp.js +0 -1
- package/dashboard/dist/assets/main-ZrWkoMlr.css +0 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const cp = require("node:child_process");
|
|
2
|
+
|
|
3
|
+
// Shared process-listing primitives. Extracted from usage-limits.js so that a
|
|
4
|
+
// second caller (transcript-suppression.js) can reuse the parser instead of
|
|
5
|
+
// keeping a second copy of the same regex in the tree.
|
|
6
|
+
//
|
|
7
|
+
// PRIVACY: callers get raw command lines here, and a command line can contain a
|
|
8
|
+
// user's file paths. Nothing in this module writes, logs, caches, or returns a
|
|
9
|
+
// command line beyond the synchronous call — that constraint belongs to every
|
|
10
|
+
// caller, and CONTRIBUTING.md's rule ("never log, store, transmit, or print ...
|
|
11
|
+
// file paths from user code") is what it exists to satisfy.
|
|
12
|
+
|
|
13
|
+
const PS_BINARY = "/bin/ps";
|
|
14
|
+
// `-x` (own user, including processes with no controlling terminal) rather than
|
|
15
|
+
// `-ax` (every user on the box). The suppression check reports its findings over
|
|
16
|
+
// an unauthenticated loopback endpoint, and on a multi-user host `-a` would make
|
|
17
|
+
// that endpoint answer questions about other people's sessions. Scoping the scan
|
|
18
|
+
// itself is the narrow fix: a session TokenTracker could not have recorded
|
|
19
|
+
// anyway is one this user is not running.
|
|
20
|
+
//
|
|
21
|
+
// Verified on both supported platforms rather than assumed from documented
|
|
22
|
+
// semantics, because Linux `ps` is procps and parses dash-prefixed options as
|
|
23
|
+
// UNIX-style, where `-x` is not an option at all. It does accept this as the BSD
|
|
24
|
+
// `x`: on Debian 12 / procps-ng 4.0.2, `ps -x -o pid=,command=` exits 0 and
|
|
25
|
+
// lists one user, while `-ax` on the same box lists seven. macOS/BSD `ps` is the
|
|
26
|
+
// native case. Had procps rejected it, every Linux host would have fallen into
|
|
27
|
+
// `process_list_failed` — a permanent non-advisory warn, which would pin
|
|
28
|
+
// `degraded` for a whole platform.
|
|
29
|
+
//
|
|
30
|
+
// Frozen because two modules now share this array. Importing one constant stops
|
|
31
|
+
// the two scans from drifting apart editorially; freezing is what stops a caller
|
|
32
|
+
// pushing `-a` onto it at runtime.
|
|
33
|
+
const PS_ARGS = Object.freeze(["-x", "-o", "pid=,command="]);
|
|
34
|
+
const PS_TIMEOUT_MS = 4000;
|
|
35
|
+
const PS_MAX_BUFFER = 10 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
function parseProcessLine(line) {
|
|
38
|
+
const match = String(line || "")
|
|
39
|
+
.trim()
|
|
40
|
+
.match(/^(\d+)\s+(.*)$/);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
return {
|
|
43
|
+
pid: Number(match[1]),
|
|
44
|
+
command: match[2],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `/bin/ps` with these flags is a POSIX assumption. Windows has no equivalent
|
|
49
|
+
// at this path, and guessing at `tasklist` output would be an untested code
|
|
50
|
+
// path, so the honest answer there is "not supported" rather than "no problems
|
|
51
|
+
// found" — the caller must not turn this into a passing check.
|
|
52
|
+
function isProcessListSupported(platform = process.platform) {
|
|
53
|
+
return platform !== "win32";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Returns { supported, ok, lines, reason }. `ok: false` never throws: a machine
|
|
57
|
+
// that refuses `ps` (sandbox, hardened runtime) is a normal condition here, and
|
|
58
|
+
// the caller reports it as "could not check" rather than as "nothing found".
|
|
59
|
+
function listProcessLines({ commandRunner, platform = process.platform } = {}) {
|
|
60
|
+
if (!isProcessListSupported(platform)) {
|
|
61
|
+
return { supported: false, ok: false, lines: [], reason: "unsupported_platform" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
|
|
65
|
+
let result;
|
|
66
|
+
try {
|
|
67
|
+
result = runner(PS_BINARY, PS_ARGS, {
|
|
68
|
+
encoding: "utf8",
|
|
69
|
+
maxBuffer: PS_MAX_BUFFER,
|
|
70
|
+
timeout: PS_TIMEOUT_MS,
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (result?.error || result?.status !== 0) {
|
|
77
|
+
return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stdout = typeof result?.stdout === "string" ? result.stdout : "";
|
|
81
|
+
return { supported: true, ok: true, lines: stdout.split("\n"), reason: null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
PS_ARGS,
|
|
86
|
+
PS_BINARY,
|
|
87
|
+
PS_TIMEOUT_MS,
|
|
88
|
+
isProcessListSupported,
|
|
89
|
+
listProcessLines,
|
|
90
|
+
parseProcessLine,
|
|
91
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Compaction for the append-only queue, and the row invariant that lived in
|
|
4
|
+
// prose.
|
|
5
|
+
//
|
|
6
|
+
// Measured on a real install: 34,492 lines, 5,595 unique keys, 28,897
|
|
7
|
+
// superseded (83.8%), 11 MB. Five sixths of the file is dead weight, and
|
|
8
|
+
// `readQueueData` in local-api.js re-reads and re-dedups ALL of it on every
|
|
9
|
+
// endpoint call — a dashboard refresh hits 6-8 endpoints, auto-refresh defaults
|
|
10
|
+
// to 30s. Nothing ever reclaims it; the only rewrite in the codebase is a
|
|
11
|
+
// one-off migration.
|
|
12
|
+
//
|
|
13
|
+
// The design risk here is close to zero for one reason: THE READERS ALREADY
|
|
14
|
+
// DEFINE THE OUTPUT. `readQueueData` keeps the last row per
|
|
15
|
+
// `source|model|hour_start`. Compaction only has to produce what every reader
|
|
16
|
+
// already computes, so it keeps the last RAW LINE per key — not a re-serialised
|
|
17
|
+
// row. Byte-identical API responses then follow by construction rather than by
|
|
18
|
+
// luck, because the surviving bytes are the exact bytes the reader would have
|
|
19
|
+
// kept.
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
const path = require("node:path");
|
|
23
|
+
|
|
24
|
+
// Must match readQueueData in src/lib/local-api.js. If that key ever changes,
|
|
25
|
+
// this one has to change with it — a test asserts they agree on real rows.
|
|
26
|
+
function queueRowKey(row) {
|
|
27
|
+
return `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TOKEN_COLUMNS = [
|
|
31
|
+
"input_tokens",
|
|
32
|
+
"output_tokens",
|
|
33
|
+
"cache_creation_input_tokens",
|
|
34
|
+
"cached_input_tokens",
|
|
35
|
+
"reasoning_output_tokens",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
// Codex and every-code report reasoning tokens that are ALREADY COUNTED inside
|
|
41
|
+
// output_tokens, so their total_tokens correctly excludes the reasoning column —
|
|
42
|
+
// adding it would count those tokens twice.
|
|
43
|
+
//
|
|
44
|
+
// This is not a special case invented here. `computeRowCost` in
|
|
45
|
+
// src/lib/pricing/index.js:309 makes exactly the same distinction, and charges
|
|
46
|
+
// reasoning at zero for these two sources for the same reason.
|
|
47
|
+
//
|
|
48
|
+
// Found by running this check against a real 34,922-row queue: 8,236 rows
|
|
49
|
+
// "violated" the invariant, every one of them source=codex, and in every case
|
|
50
|
+
// the difference was exactly reasoning_output_tokens. The rows were right, the
|
|
51
|
+
// check was wrong, and so was the prose it came from — CLAUDE.md said "sum of
|
|
52
|
+
// all columns" with no exception. Corrected there too.
|
|
53
|
+
const REASONING_FOLDED_INTO_OUTPUT = new Set(["codex", "every-code"]);
|
|
54
|
+
|
|
55
|
+
function expectedTotal(row) {
|
|
56
|
+
const folded = REASONING_FOLDED_INTO_OUTPUT.has(String(row.source || "").toLowerCase());
|
|
57
|
+
return TOKEN_COLUMNS.reduce((acc, column) => {
|
|
58
|
+
if (folded && column === "reasoning_output_tokens") return acc;
|
|
59
|
+
const value = Number(row[column] ?? 0);
|
|
60
|
+
return acc + (Number.isFinite(value) ? value : 0);
|
|
61
|
+
}, 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Decides which lines survive, without touching the disk. Split out so the
|
|
65
|
+
// decision is testable on its own and so `analyze` and `compact` cannot drift.
|
|
66
|
+
//
|
|
67
|
+
// Malformed lines are KEPT. They are invisible to every reader already, so
|
|
68
|
+
// dropping them would not change a single API response — but it would destroy
|
|
69
|
+
// bytes nobody has looked at, and a partial write worth investigating is
|
|
70
|
+
// exactly the kind of thing that should survive a routine maintenance command.
|
|
71
|
+
function planCompaction(raw) {
|
|
72
|
+
const lines = raw.split("\n");
|
|
73
|
+
const keep = new Set();
|
|
74
|
+
const lastForKey = new Map();
|
|
75
|
+
let parseable = 0;
|
|
76
|
+
let malformed = 0;
|
|
77
|
+
|
|
78
|
+
lines.forEach((line, index) => {
|
|
79
|
+
if (!line.trim()) return;
|
|
80
|
+
let row;
|
|
81
|
+
try {
|
|
82
|
+
row = JSON.parse(line);
|
|
83
|
+
} catch {
|
|
84
|
+
malformed += 1;
|
|
85
|
+
keep.add(index);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
parseable += 1;
|
|
89
|
+
lastForKey.set(queueRowKey(row), index);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
for (const index of lastForKey.values()) keep.add(index);
|
|
93
|
+
|
|
94
|
+
const kept = [...keep].sort((a, b) => a - b);
|
|
95
|
+
return {
|
|
96
|
+
lines: kept.map((index) => lines[index]),
|
|
97
|
+
stats: {
|
|
98
|
+
totalLines: parseable + malformed,
|
|
99
|
+
parseable,
|
|
100
|
+
malformed,
|
|
101
|
+
uniqueKeys: lastForKey.size,
|
|
102
|
+
superseded: parseable - lastForKey.size,
|
|
103
|
+
keptLines: kept.length,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function analyzeQueue(queuePath) {
|
|
109
|
+
let raw;
|
|
110
|
+
try {
|
|
111
|
+
raw = fs.readFileSync(queuePath, "utf8");
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (e?.code === "ENOENT") {
|
|
114
|
+
return { totalLines: 0, parseable: 0, malformed: 0, uniqueKeys: 0, superseded: 0, keptLines: 0, ratio: 0, bytes: 0 };
|
|
115
|
+
}
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
const { stats } = planCompaction(raw);
|
|
119
|
+
return {
|
|
120
|
+
...stats,
|
|
121
|
+
ratio: stats.parseable > 0 ? stats.superseded / stats.parseable : 0,
|
|
122
|
+
bytes: Buffer.byteLength(raw, "utf8"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Writes to a temp file in the same directory and renames over the original.
|
|
127
|
+
// Same atomic-replace pattern the project-queue rewrite already uses. An
|
|
128
|
+
// interrupt between write and rename leaves the original untouched — the temp
|
|
129
|
+
// file is the only casualty.
|
|
130
|
+
//
|
|
131
|
+
// The CALLER holds the sync lock. This does not take it, because the lock is
|
|
132
|
+
// per-invocation state owned by the sync command, and a second acquisition
|
|
133
|
+
// inside would deadlock against the first.
|
|
134
|
+
function compactQueue(queuePath) {
|
|
135
|
+
let raw;
|
|
136
|
+
try {
|
|
137
|
+
raw = fs.readFileSync(queuePath, "utf8");
|
|
138
|
+
} catch (e) {
|
|
139
|
+
if (e?.code === "ENOENT") return { changed: false, reason: "no queue file" };
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const before = Buffer.byteLength(raw, "utf8");
|
|
144
|
+
const { lines, stats } = planCompaction(raw);
|
|
145
|
+
if (stats.superseded === 0) {
|
|
146
|
+
return { changed: false, reason: "nothing superseded", ...stats, bytesBefore: before, bytesAfter: before };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const out = lines.join("\n") + "\n";
|
|
150
|
+
const tmp = path.join(
|
|
151
|
+
path.dirname(queuePath),
|
|
152
|
+
`${path.basename(queuePath)}.compact.${process.pid}.tmp`,
|
|
153
|
+
);
|
|
154
|
+
fs.writeFileSync(tmp, out, "utf8");
|
|
155
|
+
try {
|
|
156
|
+
fs.renameSync(tmp, queuePath);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
fs.unlinkSync(tmp);
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
changed: true,
|
|
164
|
+
...stats,
|
|
165
|
+
bytesBefore: before,
|
|
166
|
+
bytesAfter: Buffer.byteLength(out, "utf8"),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// CLAUDE.md states the column invariant in prose:
|
|
171
|
+
//
|
|
172
|
+
// total = input + output + cache_creation + cache_read + reasoning
|
|
173
|
+
//
|
|
174
|
+
// Nothing enforced it at runtime. A miswritten or corrupt row was aggregated and
|
|
175
|
+
// rendered, not flagged — and a parser bug of exactly this shape is the class
|
|
176
|
+
// CLAUDE.md records at 1.6-7x magnitude. Same conversion as the curated-expiry
|
|
177
|
+
// and version-lockstep checks: a rule that lived in a document starts running.
|
|
178
|
+
//
|
|
179
|
+
// Returns one finding per violating row, capped by the caller.
|
|
180
|
+
function findRowViolations(rows) {
|
|
181
|
+
const findings = [];
|
|
182
|
+
rows.forEach((row, index) => {
|
|
183
|
+
const where = `row ${index + 1} (${row.source || "?"}|${row.model || "?"}|${row.hour_start || "?"})`;
|
|
184
|
+
|
|
185
|
+
for (const column of TOKEN_COLUMNS) {
|
|
186
|
+
const value = Number(row[column] ?? 0);
|
|
187
|
+
if (!Number.isFinite(value)) {
|
|
188
|
+
findings.push(`${where}: ${column} is not a number (${JSON.stringify(row[column])})`);
|
|
189
|
+
} else if (value < 0) {
|
|
190
|
+
findings.push(`${where}: ${column} is negative (${value})`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sum = expectedTotal(row);
|
|
195
|
+
const total = Number(row.total_tokens ?? 0);
|
|
196
|
+
if (Number.isFinite(total) && total !== sum) {
|
|
197
|
+
findings.push(`${where}: total_tokens ${total} != expected ${sum}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const bucket = Date.parse(row.hour_start);
|
|
201
|
+
if (!Number.isFinite(bucket)) {
|
|
202
|
+
findings.push(`${where}: hour_start is not a timestamp`);
|
|
203
|
+
} else if (bucket % THIRTY_MINUTES_MS !== 0) {
|
|
204
|
+
findings.push(`${where}: hour_start is not on a 30-minute UTC boundary`);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
return findings;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
queueRowKey,
|
|
212
|
+
expectedTotal,
|
|
213
|
+
REASONING_FOLDED_INTO_OUTPUT,
|
|
214
|
+
planCompaction,
|
|
215
|
+
analyzeQueue,
|
|
216
|
+
compactQueue,
|
|
217
|
+
findRowViolations,
|
|
218
|
+
TOKEN_COLUMNS,
|
|
219
|
+
THIRTY_MINUTES_MS,
|
|
220
|
+
};
|
package/src/lib/rollout.js
CHANGED
|
@@ -937,11 +937,12 @@ async function parseRolloutFile({
|
|
|
937
937
|
projectState,
|
|
938
938
|
currentProjectKey,
|
|
939
939
|
source,
|
|
940
|
+
model,
|
|
940
941
|
bucketStart,
|
|
941
942
|
currentProjectRef,
|
|
942
943
|
);
|
|
943
944
|
addTotals(projectBucket.totals, delta);
|
|
944
|
-
projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, bucketStart));
|
|
945
|
+
projectTouchedBuckets.add(projectBucketKey(currentProjectKey, source, model, bucketStart));
|
|
945
946
|
}
|
|
946
947
|
eventsAggregated += 1;
|
|
947
948
|
}
|
|
@@ -1061,11 +1062,12 @@ async function parseClaudeFile({
|
|
|
1061
1062
|
projectState,
|
|
1062
1063
|
projectKey,
|
|
1063
1064
|
source,
|
|
1065
|
+
model,
|
|
1064
1066
|
bucketStart,
|
|
1065
1067
|
projectRef,
|
|
1066
1068
|
);
|
|
1067
1069
|
addTotals(projectBucket.totals, delta);
|
|
1068
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
|
|
1070
|
+
projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
|
|
1069
1071
|
}
|
|
1070
1072
|
eventsAggregated += 1;
|
|
1071
1073
|
}
|
|
@@ -1145,11 +1147,12 @@ async function parseGeminiFile({
|
|
|
1145
1147
|
projectState,
|
|
1146
1148
|
projectKey,
|
|
1147
1149
|
source,
|
|
1150
|
+
model,
|
|
1148
1151
|
bucketStart,
|
|
1149
1152
|
projectRef,
|
|
1150
1153
|
);
|
|
1151
1154
|
addTotals(projectBucket.totals, delta);
|
|
1152
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
|
|
1155
|
+
projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
|
|
1153
1156
|
}
|
|
1154
1157
|
eventsAggregated += 1;
|
|
1155
1158
|
totals = currentTotals;
|
|
@@ -1256,11 +1259,12 @@ async function parseOpencodeMessageFile({
|
|
|
1256
1259
|
projectState,
|
|
1257
1260
|
projectKey,
|
|
1258
1261
|
source,
|
|
1262
|
+
model,
|
|
1259
1263
|
bucketStart,
|
|
1260
1264
|
projectRef,
|
|
1261
1265
|
);
|
|
1262
1266
|
addTotals(projectBucket.totals, delta);
|
|
1263
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
|
|
1267
|
+
projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
|
|
1264
1268
|
}
|
|
1265
1269
|
return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
|
|
1266
1270
|
}
|
|
@@ -1598,6 +1602,7 @@ async function enqueueTouchedProjectBuckets({
|
|
|
1598
1602
|
project_ref: projectRef,
|
|
1599
1603
|
project_key: projectKey,
|
|
1600
1604
|
source: bucket.source,
|
|
1605
|
+
model: bucket.model || PROJECT_MODEL_UNATTRIBUTED,
|
|
1601
1606
|
hour_start: bucket.hour_start,
|
|
1602
1607
|
input_tokens: totals.input_tokens,
|
|
1603
1608
|
cached_input_tokens: totals.cached_input_tokens,
|
|
@@ -1767,12 +1772,16 @@ function normalizeProjectState(raw) {
|
|
|
1767
1772
|
projects[key] = { ...value };
|
|
1768
1773
|
}
|
|
1769
1774
|
|
|
1770
|
-
|
|
1771
|
-
version:
|
|
1775
|
+
const normalized = {
|
|
1776
|
+
version: 3,
|
|
1772
1777
|
buckets,
|
|
1773
1778
|
projects,
|
|
1774
1779
|
updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
|
|
1775
1780
|
};
|
|
1781
|
+
// Every parser reaches project state through here, so one call covers all of
|
|
1782
|
+
// them. Idempotent: a v3 key already has four segments and is skipped.
|
|
1783
|
+
migrateProjectBucketsToModelKey(normalized);
|
|
1784
|
+
return normalized;
|
|
1776
1785
|
}
|
|
1777
1786
|
|
|
1778
1787
|
function normalizeOpencodeState(raw) {
|
|
@@ -1819,10 +1828,11 @@ function getHourlyBucket(state, source, model, hourStart) {
|
|
|
1819
1828
|
return bucket;
|
|
1820
1829
|
}
|
|
1821
1830
|
|
|
1822
|
-
function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
|
|
1831
|
+
function getProjectBucket(state, projectKey, source, model, hourStart, projectRef) {
|
|
1823
1832
|
const buckets = state.buckets;
|
|
1824
1833
|
const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
1825
|
-
const
|
|
1834
|
+
const normalizedModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED;
|
|
1835
|
+
const key = projectBucketKey(projectKey, normalizedSource, normalizedModel, hourStart);
|
|
1826
1836
|
let bucket = buckets[key];
|
|
1827
1837
|
if (!bucket || typeof bucket !== "object") {
|
|
1828
1838
|
bucket = {
|
|
@@ -1831,6 +1841,7 @@ function getProjectBucket(state, projectKey, source, hourStart, projectRef) {
|
|
|
1831
1841
|
project_key: projectKey,
|
|
1832
1842
|
project_ref: projectRef,
|
|
1833
1843
|
source: normalizedSource,
|
|
1844
|
+
model: normalizedModel,
|
|
1834
1845
|
hour_start: hourStart,
|
|
1835
1846
|
};
|
|
1836
1847
|
buckets[key] = bucket;
|
|
@@ -1921,9 +1932,53 @@ function bucketKey(source, model, hourStart) {
|
|
|
1921
1932
|
return `${safeSource}${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`;
|
|
1922
1933
|
}
|
|
1923
1934
|
|
|
1924
|
-
|
|
1935
|
+
// Per-repo COST is per-model, and the key had no model in it, so the repo x model
|
|
1936
|
+
// join this product's README promises ("which repo, which model, and which
|
|
1937
|
+
// hour") had never existed. Adding model is what makes computeRowCost usable
|
|
1938
|
+
// per project.
|
|
1939
|
+
//
|
|
1940
|
+
// The transition hazard is the whole job. A legacy row keyed
|
|
1941
|
+
// project|source|hour and a new row keyed project|source|model|hour describe the
|
|
1942
|
+
// SAME bucket at different granularity, so a reader that keeps both
|
|
1943
|
+
// double-counts. Rows written before this change are keyed with
|
|
1944
|
+
// PROJECT_MODEL_UNATTRIBUTED, so they occupy one slot alongside the model-keyed
|
|
1945
|
+
// rows rather than shadowing them — see migrateProjectBucketsToModelKey.
|
|
1946
|
+
const PROJECT_MODEL_UNATTRIBUTED = "unknown";
|
|
1947
|
+
|
|
1948
|
+
function projectBucketKey(projectKey, source, model, hourStart) {
|
|
1925
1949
|
const safeSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
|
|
1926
|
-
|
|
1950
|
+
const safeModel = normalizeModelInput(model) || PROJECT_MODEL_UNATTRIBUTED;
|
|
1951
|
+
return (
|
|
1952
|
+
`${projectKey}${BUCKET_SEPARATOR}${safeSource}` +
|
|
1953
|
+
`${BUCKET_SEPARATOR}${safeModel}${BUCKET_SEPARATOR}${hourStart}`
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// Re-keys buckets carried in cursors.json from the pre-model key to the new one,
|
|
1958
|
+
// tagging them PROJECT_MODEL_UNATTRIBUTED.
|
|
1959
|
+
//
|
|
1960
|
+
// Without this the old bucket keeps its old key, never matches again, and its
|
|
1961
|
+
// running total is stranded: new usage in the same hour starts from zero under
|
|
1962
|
+
// the model key while the reader still sees the old row. Sum = double count.
|
|
1963
|
+
//
|
|
1964
|
+
// After it, the stranded bucket becomes the `unknown` slot for that hour. It
|
|
1965
|
+
// stops receiving new usage (that goes to the model-specific bucket), so it is
|
|
1966
|
+
// frozen at its pre-upgrade total and the two sum to the right number — no
|
|
1967
|
+
// double count, and nothing lost.
|
|
1968
|
+
function migrateProjectBucketsToModelKey(projectState) {
|
|
1969
|
+
const buckets = projectState?.buckets;
|
|
1970
|
+
if (!buckets || typeof buckets !== "object") return 0;
|
|
1971
|
+
let migrated = 0;
|
|
1972
|
+
for (const [key, bucket] of Object.entries(buckets)) {
|
|
1973
|
+
if (key.split(BUCKET_SEPARATOR).length !== 3) continue;
|
|
1974
|
+
const [projectKey, source, hourStart] = key.split(BUCKET_SEPARATOR);
|
|
1975
|
+
const nextKey = projectBucketKey(projectKey, source, PROJECT_MODEL_UNATTRIBUTED, hourStart);
|
|
1976
|
+
if (buckets[nextKey]) continue;
|
|
1977
|
+
buckets[nextKey] = { ...bucket, model: PROJECT_MODEL_UNATTRIBUTED };
|
|
1978
|
+
delete buckets[key];
|
|
1979
|
+
migrated += 1;
|
|
1980
|
+
}
|
|
1981
|
+
return migrated;
|
|
1927
1982
|
}
|
|
1928
1983
|
|
|
1929
1984
|
function groupBucketKey(source, hourStart) {
|
|
@@ -2663,11 +2718,14 @@ async function parseOpencodeDbIncremental({
|
|
|
2663
2718
|
projectState,
|
|
2664
2719
|
projectKey,
|
|
2665
2720
|
defaultSource,
|
|
2721
|
+
model,
|
|
2666
2722
|
bucketStart,
|
|
2667
2723
|
projectRef,
|
|
2668
2724
|
);
|
|
2669
2725
|
addTotals(projectBucket.totals, delta);
|
|
2670
|
-
projectTouchedBuckets.add(
|
|
2726
|
+
projectTouchedBuckets.add(
|
|
2727
|
+
projectBucketKey(projectKey, defaultSource, model, bucketStart),
|
|
2728
|
+
);
|
|
2671
2729
|
}
|
|
2672
2730
|
}
|
|
2673
2731
|
|
|
@@ -3340,8 +3398,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
|
|
|
3340
3398
|
// Skip if delta is zero (session unchanged since last sync)
|
|
3341
3399
|
if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
|
|
3342
3400
|
|
|
3343
|
-
//
|
|
3344
|
-
|
|
3401
|
+
// A first observation has only the session start as a usable timestamp.
|
|
3402
|
+
// Attribute later active-session deltas to this sync so cross-day usage
|
|
3403
|
+
// does not keep growing the day on which the session originally started.
|
|
3404
|
+
// Once Hermes records completion, ended_at is authoritative for the final delta.
|
|
3405
|
+
const epochSec = endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
|
|
3345
3406
|
if (!epochSec || !Number.isFinite(epochSec)) continue;
|
|
3346
3407
|
const tsIso = new Date(epochSec * 1000).toISOString();
|
|
3347
3408
|
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
@@ -8608,11 +8669,12 @@ async function parseAntigravityFile({
|
|
|
8608
8669
|
projectState,
|
|
8609
8670
|
projectKey,
|
|
8610
8671
|
source,
|
|
8672
|
+
model,
|
|
8611
8673
|
bucketStart,
|
|
8612
8674
|
projectRef,
|
|
8613
8675
|
);
|
|
8614
8676
|
addTotals(projectBucket.totals, delta);
|
|
8615
|
-
projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
|
|
8677
|
+
projectTouchedBuckets.add(projectBucketKey(projectKey, source, model, bucketStart));
|
|
8616
8678
|
}
|
|
8617
8679
|
eventsAggregated += 1;
|
|
8618
8680
|
// Snapshot the pre-planner context first. The planner's own content+tool_calls
|
|
@@ -8707,6 +8769,11 @@ function isCjkCodePoint(code) {
|
|
|
8707
8769
|
}
|
|
8708
8770
|
|
|
8709
8771
|
module.exports = {
|
|
8772
|
+
// Exported for tests: the mixed-era rule is the risky part of this change.
|
|
8773
|
+
projectBucketKey,
|
|
8774
|
+
migrateProjectBucketsToModelKey,
|
|
8775
|
+
normalizeProjectState,
|
|
8776
|
+
PROJECT_MODEL_UNATTRIBUTED,
|
|
8710
8777
|
listRolloutFiles,
|
|
8711
8778
|
listClaudeProjectFiles,
|
|
8712
8779
|
listGeminiSessionFiles,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Coalesce concurrent calls to one expensive async producer.
|
|
5
|
+
*
|
|
6
|
+
* The problem it solves is not "the same work runs twice" — it is that the work
|
|
7
|
+
* being duplicated fans out to every configured provider's private endpoint. Two
|
|
8
|
+
* dashboard tabs, a route mount, and a scheduled revalidation can all arrive
|
|
9
|
+
* inside the same second on a cold cache; without this, each one launches its own
|
|
10
|
+
* full sweep.
|
|
11
|
+
*
|
|
12
|
+
* `run(fn)` returns the in-flight promise when one exists, so every caller in a
|
|
13
|
+
* window shares a single execution and a single result object. The slot is
|
|
14
|
+
* released as soon as the work settles — success or failure — so the next call
|
|
15
|
+
* starts fresh work rather than replaying a stale outcome.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NOT keyed by argument: the single consumer here has one shared
|
|
18
|
+
* result for the whole process. A joining caller therefore receives the FIRST
|
|
19
|
+
* caller's work, arguments included. That is the intended trade for the fan-out,
|
|
20
|
+
* and it is safe only because every production call site passes the same inputs.
|
|
21
|
+
*
|
|
22
|
+
* @returns {(fn: () => Promise<any>) => Promise<any>}
|
|
23
|
+
*/
|
|
24
|
+
function createSingleFlight() {
|
|
25
|
+
let inFlight = null;
|
|
26
|
+
|
|
27
|
+
return function run(fn) {
|
|
28
|
+
if (inFlight) return inFlight;
|
|
29
|
+
|
|
30
|
+
// `Promise.resolve().then(fn)` rather than `fn()` so a synchronous throw
|
|
31
|
+
// inside fn becomes a rejection on this path too, instead of escaping past
|
|
32
|
+
// the slot cleanup and wedging `inFlight` forever.
|
|
33
|
+
//
|
|
34
|
+
// Release carries no `inFlight === pending` identity guard, deliberately. A
|
|
35
|
+
// later run can only claim the slot after this one released it — while
|
|
36
|
+
// `pending` is unsettled every arrival joins instead of replacing it — so a
|
|
37
|
+
// stale callback clearing a successor's slot is unreachable, and a mutation
|
|
38
|
+
// test found the guard dead. Adding a way to clear the slot from outside
|
|
39
|
+
// would make it reachable again; there is none, and #141 requires that a
|
|
40
|
+
// cache reset specifically must not do it.
|
|
41
|
+
const pending = Promise.resolve()
|
|
42
|
+
.then(fn)
|
|
43
|
+
.finally(() => {
|
|
44
|
+
inFlight = null;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Module state now holds a promise that real callers may all walk away from.
|
|
48
|
+
// Without a handler of its own, a rejection reaching only this reference is
|
|
49
|
+
// an unhandledRejection raised from state nobody is watching — a failure mode
|
|
50
|
+
// that did not exist while every caller owned its own promise. Callers still
|
|
51
|
+
// receive the rejection; this only marks the stored reference as handled.
|
|
52
|
+
pending.catch(() => {});
|
|
53
|
+
|
|
54
|
+
inFlight = pending;
|
|
55
|
+
return pending;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { createSingleFlight };
|
|
@@ -287,13 +287,13 @@ function hashDirectory(dir) {
|
|
|
287
287
|
continue;
|
|
288
288
|
}
|
|
289
289
|
const execBit = process.platform === "win32" ? 0 : stat.mode & 0o111 ? 1 : 0;
|
|
290
|
-
hash.update(`${rel}
|
|
290
|
+
hash.update(`${rel}\0${execBit}\0`);
|
|
291
291
|
try {
|
|
292
292
|
hash.update(fs.readFileSync(abs));
|
|
293
293
|
} catch (_e) {
|
|
294
294
|
// unreadable file — fold its absence in deterministically
|
|
295
295
|
}
|
|
296
|
-
hash.update("
|
|
296
|
+
hash.update("\0");
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
};
|