taskchef 9.3.0 → 9.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.
- package/.codex-plugin/plugin.json +1 -1
- package/docs/mcp-app.md +47 -4
- package/package.json +1 -1
- package/src/codex-rollout.js +3 -3
- package/src/codex-session-scanner.js +39 -4
- package/src/codex-session-usage.js +54 -20
- package/src/mcp-app/dist/index.html +110 -110
- package/src/mcp-app/react/UsageView.test.tsx +103 -0
- package/src/mcp-app/react/UsageView.tsx +128 -0
- package/src/mcp-app/react/main.test.tsx +16 -2
- package/src/mcp-app/react/main.tsx +11 -4
- package/src/mcp-app/react/styles.css +54 -7
- package/src/mcp-app.js +37 -0
- package/src/usage-analytics.js +47 -0
package/docs/mcp-app.md
CHANGED
|
@@ -83,14 +83,13 @@ Open the installed TaskChef plugin details page to edit its native
|
|
|
83
83
|
settings. The sidebar receives them at its next visible refresh, or immediately
|
|
84
84
|
when Refresh is clicked. Settings persist across sidebar and embedded views.
|
|
85
85
|
The old browser-local visibility keys are no longer used; configure the native
|
|
86
|
-
switches once after upgrading. The Board
|
|
86
|
+
switches once after upgrading. The Board opens by default.
|
|
87
87
|
See the [OpenAI structured settings specification](https://github.com/openai/mcp-extensions/blob/main/docs/spec.md#structured-settings).
|
|
88
88
|
|
|
89
89
|
### Board presentation
|
|
90
90
|
|
|
91
|
-
TaskChef exposes
|
|
92
|
-
source, but
|
|
93
|
-
ignored. Interrupted chats appear in Waiting for input/review with an
|
|
91
|
+
TaskChef exposes Board and Usage. The List implementation remains in the
|
|
92
|
+
source, but List is hidden and saved List choices are ignored. Interrupted chats appear in Waiting for input/review with an
|
|
94
93
|
Interrupted tag; their recorded status remains available in Details.
|
|
95
94
|
Scheduled, Running, Waiting, and Done remain visible when empty, with a short
|
|
96
95
|
message specific to each column. This keeps their positions steady when chats
|
|
@@ -365,3 +364,47 @@ Retire the old shared catalog entry only after this repository's catalog is
|
|
|
365
364
|
live, the release workflow has completed, and a clean installation from the
|
|
366
365
|
new marketplace succeeds. Existing users must migrate explicitly; changing
|
|
367
366
|
a marketplace does not automatically move their installed plugin identity.
|
|
367
|
+
|
|
368
|
+
## Usage analytics
|
|
369
|
+
|
|
370
|
+

|
|
371
|
+
|
|
372
|
+
The Board/Usage selector sits beside the logo in the navbar, separated from
|
|
373
|
+
Board filters by a divider. It opens an on-demand historical usage view. Ranges are
|
|
374
|
+
7, 30, or 90 calendar days including today, This month (the default, through
|
|
375
|
+
today), and Last month (the full previous calendar month).
|
|
376
|
+
There is no custom range. Dates use the view's local timezone, including DST.
|
|
377
|
+
Range changes and reopening Usage query one shared history snapshot without
|
|
378
|
+
rescanning logs. The snapshot is scoped by CLI/exec visibility, independently of
|
|
379
|
+
range or timezone. After one minute it refreshes in the background while cached
|
|
380
|
+
charts remain visible. Refresh explicitly rebuilds it, reusing existing per-file
|
|
381
|
+
metadata and incremental parsing caches. Unchanged files retain parsed records
|
|
382
|
+
on disk across restarts and turn or price changes. A session filename index
|
|
383
|
+
avoids searching the full inventory for each chat. Missing paths do not incur
|
|
384
|
+
reader retry delays. Empty or mismatched files are checked again when their
|
|
385
|
+
metadata changes; transient I/O failures retry while retaining healthy sources.
|
|
386
|
+
Rescan explicitly reparses available files and refreshes the inventory, while
|
|
387
|
+
retaining known history for missing sources. Board polling never starts a history scan.
|
|
388
|
+
|
|
389
|
+
The combined chart uses cost bars on the left axis and a token line on the
|
|
390
|
+
right. Today's cost bars have a lighter fill and dashed borders in both charts;
|
|
391
|
+
the token segment leading into today is dashed too. Finished days, including
|
|
392
|
+
the last day of Last month, stay solid. The project chart uses stacked cost bars for the period's top five
|
|
393
|
+
projects plus Other. Select a legend entry or a Top 10 row to isolate that
|
|
394
|
+
project in the project chart; All projects restores the stack. Hovering or
|
|
395
|
+
focusing a legend entry highlights its bars and dims other project colors. Totals, the
|
|
396
|
+
combined chart, and rankings always describe the full selected period.
|
|
397
|
+
|
|
398
|
+
Archived chats and descendant usage are included. CLI/exec visibility settings
|
|
399
|
+
still apply to root chats. Descendants are counted once under their root chat's
|
|
400
|
+
current project. Unassigned chats share one Unassigned project. Daily usage
|
|
401
|
+
comes from recorded call timestamps, not chat creation or last-update dates.
|
|
402
|
+
Unexplained cumulative counter gaps stay in lifetime totals but are excluded
|
|
403
|
+
from daily totals because their dates are unknown. Missing records or prices
|
|
404
|
+
remain marked in the analytics response without displaying coverage warnings.
|
|
405
|
+
Unknown costs are excluded from estimated cost.
|
|
406
|
+
Cached input is part of input tokens, and reasoning output is part of output.
|
|
407
|
+
Cost is an estimate at current API-equivalent rates, not subscription billing
|
|
408
|
+
or remaining allowance. The first read shows a progress bar with the actual number of root chats
|
|
409
|
+
processed and polls an app-only tool. Cached charts stay visible during later
|
|
410
|
+
refreshes, with their last-update time and scan progress.
|
package/package.json
CHANGED
package/src/codex-rollout.js
CHANGED
|
@@ -4,19 +4,19 @@ import * as zlib from "node:zlib";
|
|
|
4
4
|
export function plainRolloutPath(path) { return path.replace(/\.jsonl\.zst$/, ".jsonl"); }
|
|
5
5
|
|
|
6
6
|
// Codex can replace either representation while readers are resolving a path.
|
|
7
|
-
async function resolve(path, inspect) {
|
|
7
|
+
async function resolve(path, inspect, retries = 3) {
|
|
8
8
|
const plain = plainRolloutPath(path);
|
|
9
9
|
for (let attempt = 0; ; attempt++) {
|
|
10
10
|
for (const candidate of [plain, `${plain}.zst`]) {
|
|
11
11
|
try { return { path: candidate, ...await inspect(candidate) }; }
|
|
12
12
|
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
13
13
|
}
|
|
14
|
-
if (attempt ===
|
|
14
|
+
if (attempt === retries) { const error = new Error("Rollout is missing."); error.code = "ENOENT"; throw error; }
|
|
15
15
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function rolloutMetadata(path) { return resolve(path, async path => ({ info: await stat(path) })); }
|
|
19
|
+
export function rolloutMetadata(path, { retries = 3 } = {}) { return resolve(path, async path => ({ info: await stat(path) }), retries); }
|
|
20
20
|
export function openRollout(path) {
|
|
21
21
|
return resolve(path, async path => {
|
|
22
22
|
const handle = await open(path, "r");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { aggregateUsage } from "./usage-analytics.js";
|
|
1
2
|
import { openRollout, rolloutChunks } from "./codex-rollout.js";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { normalizeUri } from "micromark-util-sanitize-uri";
|
|
@@ -239,9 +240,10 @@ function readDatabaseRecords(state, history) {
|
|
|
239
240
|
FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal DESC LIMIT 1`);
|
|
240
241
|
const turns = new Map();
|
|
241
242
|
const needsAnswers = new Map();
|
|
242
|
-
const
|
|
243
|
+
const allRows = state.prepare(`SELECT id, name, title, cwd, archived, ${hasPinned ? "is_pinned" : "0 AS is_pinned"}, source, created_at_ms, updated_at_ms, recency_at_ms, rollout_path, history_mode, project_id,
|
|
243
244
|
(SELECT count(*) FROM thread_spawn_edges WHERE parent_thread_id = threads.id) AS child_count
|
|
244
|
-
FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all()
|
|
245
|
+
FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all();
|
|
246
|
+
const rows = allRows.filter((row) => {
|
|
245
247
|
const key = historyId(row);
|
|
246
248
|
const turn = latestTurn.get(key);
|
|
247
249
|
if (!turn) return false;
|
|
@@ -285,7 +287,7 @@ function readDatabaseRecords(state, history) {
|
|
|
285
287
|
attachments.set(row.thread_id, urls);
|
|
286
288
|
}
|
|
287
289
|
}
|
|
288
|
-
return { rows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
|
|
290
|
+
return { rows, allRows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
function buildDatabaseSnapshot(records, now, doneMarks, schedules, scheduleErrors, cacheHit) {
|
|
@@ -649,6 +651,39 @@ export class CodexSessionScanner {
|
|
|
649
651
|
) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(id);
|
|
650
652
|
return this.sessionUsage.read({ id, latestTurnId: task.turnId, sessions, savedDurations });
|
|
651
653
|
}
|
|
654
|
+
async usageAnalytics({ range, timeZone, settings }) {
|
|
655
|
+
return aggregateUsage(await this.usageHistory({ settings }), { range, timeZone, now: this.now() });
|
|
656
|
+
}
|
|
657
|
+
async usageHistory({ settings, onProgress = () => {}, rescan = false }) {
|
|
658
|
+
if (rescan) this.sessionUsage.inventoryAt = -Infinity;
|
|
659
|
+
const snapshot = await this.refresh();
|
|
660
|
+
if (!snapshot.healthy) throw new Error(snapshot.scan.error);
|
|
661
|
+
const records = this.databaseCache.records;
|
|
662
|
+
// Analytics inventories every root, even if its latest turn is absent from
|
|
663
|
+
// the board's history database. Its rollout can still hold valid usage.
|
|
664
|
+
const grouped = await this.projectCatalog.group(records.allRows.map(row => ({
|
|
665
|
+
id: row.id, turnId: records.turns.get(historyId(row))?.turn_id ?? null,
|
|
666
|
+
project: { id: row.project_id, path: row.cwd || "", githubRepos: [] }, observed: { source: row.source },
|
|
667
|
+
})), records.projects);
|
|
668
|
+
const families = [];
|
|
669
|
+
const seen = new Set();
|
|
670
|
+
// History includes archived chats. CLI/exec preferences still apply.
|
|
671
|
+
const tasks = grouped.tasks.filter(task => (settings.showCli || task.observed?.source !== "cli") && (settings.showExec || task.observed?.source !== "exec"));
|
|
672
|
+
let completed = 0;
|
|
673
|
+
onProgress({ completed, total: tasks.length });
|
|
674
|
+
for (const task of tasks) {
|
|
675
|
+
const sessions = this.connections.state.prepare(`WITH RECURSIVE family(id) AS (
|
|
676
|
+
SELECT ? UNION SELECT child_thread_id FROM thread_spawn_edges JOIN family ON parent_thread_id = family.id
|
|
677
|
+
) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(task.id).filter(row => !seen.has(row.id));
|
|
678
|
+
if (sessions.length) {
|
|
679
|
+
sessions.forEach(row => seen.add(row.id));
|
|
680
|
+
const usage = await this.sessionUsage.read({ id: task.id, latestTurnId: task.turnId, sessions, rescan });
|
|
681
|
+
families.push({ project: task.project, usage });
|
|
682
|
+
}
|
|
683
|
+
onProgress({ completed: ++completed, total: tasks.length });
|
|
684
|
+
}
|
|
685
|
+
return families;
|
|
686
|
+
}
|
|
652
687
|
recordedUsage(id, turnId) {
|
|
653
688
|
const result = (this.recordedPending ?? Promise.resolve()).then(() => this.readRecordedUsage(id, turnId));
|
|
654
689
|
this.recordedPending = result.catch(() => {});
|
|
@@ -711,7 +746,7 @@ export class CodexSessionScanner {
|
|
|
711
746
|
let detail = { ...task, ...records.latestContent.get(id), detailPullRequests, observed: { ...task.observed,
|
|
712
747
|
recordedChatDurationMs: durations.total ?? 0, missingTurnDurations: durations.missing ?? 0 } };
|
|
713
748
|
try {
|
|
714
|
-
const sessionUsage = await this.taskUsage(id, durations.saved);
|
|
749
|
+
const { timeline: _timeline, analyticsPartial: _analyticsPartial, ...sessionUsage } = await this.taskUsage(id, durations.saved);
|
|
715
750
|
detail = { ...detail, sessionUsage, observed: { ...detail.observed,
|
|
716
751
|
...(sessionUsage.durationTurns ? { recordedChatDurationMs: Math.max(durations.total ?? 0, sessionUsage.durationMs) } : {}),
|
|
717
752
|
historicalTimePartial: sessionUsage.partial || !sessionUsage.durationTurns } };
|
|
@@ -19,7 +19,7 @@ const bucket = () => ({ tokens: zero(), costUsd: 0, partial: false, costPartial:
|
|
|
19
19
|
// Cumulative totals and call counters are independent. A missing boundary can
|
|
20
20
|
// leave one turn partial without preventing later calls from being allocated.
|
|
21
21
|
export function summarizeRecords(records, latestTurnId, models, child = false) {
|
|
22
|
-
const total = bucket(), latest = bucket(), durations = new Map(), seen = new Set();
|
|
22
|
+
const total = bucket(), latest = bucket(), durations = new Map(), seen = new Set(), timeline = [];
|
|
23
23
|
let previous = zero();
|
|
24
24
|
for (const record of [...records].sort((a, b) => a.time - b.time || a.order - b.order)) {
|
|
25
25
|
const key = JSON.stringify([record.time, record.kind, record.turn, record.root, record.model, record.total, record.last, record.duration]);
|
|
@@ -56,6 +56,10 @@ export function summarizeRecords(records, latestTurnId, models, child = false) {
|
|
|
56
56
|
const price = estimateCallCost(last, record.model, models);
|
|
57
57
|
total.costUsd += price ?? 0;
|
|
58
58
|
total.costPartial ||= !complete || price == null;
|
|
59
|
+
// Only the last call has a known timestamp. An unexplained cumulative gap
|
|
60
|
+
// belongs in lifetime totals, not arbitrarily on the day it was noticed.
|
|
61
|
+
if (record.time > 0) timeline.push({ time: record.time, tokens: complete || reset ? captured : last,
|
|
62
|
+
costUsd: price ?? 0, samples: 1, partial: !complete, costPartial: !complete || price == null });
|
|
59
63
|
total.partial ||= reset;
|
|
60
64
|
const belongs = (child ? record.root : record.turn) === latestTurnId;
|
|
61
65
|
if (belongs) {
|
|
@@ -67,7 +71,7 @@ export function summarizeRecords(records, latestTurnId, models, child = false) {
|
|
|
67
71
|
latest.partial = latest.costPartial = true;
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
|
-
return { total, latest, durations };
|
|
74
|
+
return { total, latest, durations, timeline };
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
async function inventory(root, paths = []) {
|
|
@@ -84,7 +88,7 @@ async function inventory(root, paths = []) {
|
|
|
84
88
|
export class CodexSessionUsage {
|
|
85
89
|
constructor({ codexHome, now = Date.now, pricing, cacheDir, legacyCacheDir }) {
|
|
86
90
|
this.home = codexHome; this.now = now; this.pricing = pricing; this.cacheDir = cacheDir; this.legacyCacheDir = legacyCacheDir;
|
|
87
|
-
this.files = new Map(); this.paths = []; this.inventoryAt = -Infinity;
|
|
91
|
+
this.files = new Map(); this.paths = []; this.pathsBySession = new Map(); this.inventoryAt = -Infinity;
|
|
88
92
|
this.pending = Promise.resolve();
|
|
89
93
|
this.results = new Map();
|
|
90
94
|
}
|
|
@@ -94,7 +98,7 @@ export class CodexSessionUsage {
|
|
|
94
98
|
this.pending = result.catch(() => {});
|
|
95
99
|
return result;
|
|
96
100
|
}
|
|
97
|
-
async readOnce({ id, latestTurnId, sessions, savedDurations = [] }) {
|
|
101
|
+
async readOnce({ id, latestTurnId, sessions, savedDurations = [], rescan = false }) {
|
|
98
102
|
if (this.legacyCacheDir && this.cacheDir) {
|
|
99
103
|
try { await migrateCache(this.legacyCacheDir, this.cacheDir); this.legacyCacheDir = null; }
|
|
100
104
|
catch { /* Optional caches never prevent reading source usage. */ }
|
|
@@ -103,6 +107,13 @@ export class CodexSessionUsage {
|
|
|
103
107
|
if (this.now() - this.inventoryAt >= 60_000) {
|
|
104
108
|
try {
|
|
105
109
|
this.paths = [...await inventory(join(this.home, "sessions")), ...await inventory(join(this.home, "archived_sessions"))];
|
|
110
|
+
this.pathsBySession = new Map();
|
|
111
|
+
for (const path of this.paths) {
|
|
112
|
+
for (const [id] of basename(path).matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi)) {
|
|
113
|
+
if (!this.pathsBySession.has(id)) this.pathsBySession.set(id, []);
|
|
114
|
+
this.pathsBySession.get(id).push(path);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
106
117
|
this.inventoryAt = this.now();
|
|
107
118
|
} catch { inventoryFailed = true; }
|
|
108
119
|
}
|
|
@@ -111,11 +122,11 @@ export class CodexSessionUsage {
|
|
|
111
122
|
const pricing = await this.pricing.get();
|
|
112
123
|
const sources = [];
|
|
113
124
|
for (const session of sessions) {
|
|
114
|
-
const paths = new Set(this.
|
|
125
|
+
const paths = new Set(this.sessionPaths(session.id));
|
|
115
126
|
if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
|
|
116
127
|
const versions = [];
|
|
117
128
|
for (const path of paths) {
|
|
118
|
-
try { const { info } = await rolloutMetadata(path); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
|
|
129
|
+
try { const { info } = await rolloutMetadata(path, { retries: 0 }); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
|
|
119
130
|
catch { versions.push([path, "missing"]); }
|
|
120
131
|
}
|
|
121
132
|
sources.push([session.id, versions]);
|
|
@@ -127,7 +138,7 @@ export class CodexSessionUsage {
|
|
|
127
138
|
if (!saved && cachePath) {
|
|
128
139
|
try {
|
|
129
140
|
const value = JSON.parse(await readFile(cachePath, "utf8"));
|
|
130
|
-
if (value.version === 1
|
|
141
|
+
if ([1, 2, 3].includes(value.version) && validResult(value.result) && (value.version === 1 || (Array.isArray(value.result.timeline) && value.result.timeline.every(sample => Number.isFinite(sample.time) && usage(sample.tokens) && Number.isFinite(sample.costUsd) && sample.costUsd >= 0 && sample.samples === 1 && typeof sample.partial === "boolean" && typeof sample.costPartial === "boolean")))) saved = value;
|
|
131
142
|
} catch { /* An absent or damaged cache is rebuilt from source records. */ }
|
|
132
143
|
}
|
|
133
144
|
const sourceKeys = (items) => items.flatMap(([sessionId, versions]) => versions
|
|
@@ -153,29 +164,41 @@ export class CodexSessionUsage {
|
|
|
153
164
|
})) knownSources = [];
|
|
154
165
|
} catch { knownSources = []; /* No prior valid source coverage is available. */ }
|
|
155
166
|
const missingSources = knownSources.filter(key => !currentSources.has(key));
|
|
156
|
-
if (saved?.recordSources && saved.sourceSignature === sourceSignature) { this.results.set(resultKey, saved); return saved.result; }
|
|
167
|
+
if (!rescan && !saved?.retryRead && saved?.version === 3 && typeof saved.result.analyticsPartial === "boolean" && saved.recordSources && saved.sourceSignature === sourceSignature) { this.results.set(resultKey, saved); return saved.result; }
|
|
157
168
|
const availablePaths = new Set(sources.flatMap(([, versions]) => versions.filter(version => version.length > 2).map(version => version[0])));
|
|
158
169
|
let retryRead = false;
|
|
159
170
|
const parsed = [];
|
|
160
171
|
const recordSources = {};
|
|
172
|
+
const sourceFiles = {};
|
|
161
173
|
for (const session of sessions) {
|
|
162
|
-
const paths = new Set(this.
|
|
174
|
+
const paths = new Set(this.sessionPaths(session.id));
|
|
163
175
|
if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
|
|
164
176
|
const records = [], versions = [];
|
|
165
177
|
let failed = inventoryFailed, latestFailed = false, timePartial = inventoryFailed;
|
|
166
178
|
if (!paths.size) failed = latestFailed = timePartial = true;
|
|
167
179
|
for (const path of paths) {
|
|
168
180
|
try {
|
|
169
|
-
const
|
|
181
|
+
const sourceKey = JSON.stringify([session.id, basename(path)]);
|
|
182
|
+
const stamp = sources.find(([sessionId]) => sessionId === session.id)?.[1].find(version => version[0] === path);
|
|
183
|
+
if (!availablePaths.has(path)) throw new Error("Rollout is missing.");
|
|
184
|
+
const previous = saved?.sourceFiles?.[sourceKey];
|
|
185
|
+
let file;
|
|
186
|
+
if (!rescan && previous && JSON.stringify(previous.stamp) === JSON.stringify(stamp) && Array.isArray(saved.recordSources?.[sourceKey])) {
|
|
187
|
+
file = { records: saved.recordSources[sourceKey], partial: previous.partial };
|
|
188
|
+
} else {
|
|
189
|
+
if (rescan) this.files.delete(path);
|
|
190
|
+
file = await this.readFile(path, session.id);
|
|
191
|
+
}
|
|
192
|
+
sourceFiles[sourceKey] = { stamp, partial: file.partial };
|
|
170
193
|
for (const record of file.records) records.push(record);
|
|
171
194
|
recordSources[JSON.stringify([session.id, basename(path)])] = file.records;
|
|
172
195
|
versions.push([path, file.identity, file.size, file.mtime]);
|
|
173
196
|
timePartial ||= file.partial;
|
|
174
|
-
} catch {
|
|
197
|
+
} catch (error) {
|
|
175
198
|
failed = timePartial = true;
|
|
176
199
|
const sourceKey = JSON.stringify([session.id, basename(path)]);
|
|
177
200
|
if (Array.isArray(saved?.recordSources?.[sourceKey]) && !missingSources.includes(sourceKey)) missingSources.push(sourceKey);
|
|
178
|
-
retryRead ||= availablePaths.has(path);
|
|
201
|
+
retryRead ||= availablePaths.has(path) && error.code !== "INVALID_ROLLOUT";
|
|
179
202
|
latestFailed ||= session.id !== id || path === plainRolloutPath(session.rollout_path ?? "");
|
|
180
203
|
versions.push([path, "missing"]);
|
|
181
204
|
}
|
|
@@ -188,7 +211,7 @@ export class CodexSessionUsage {
|
|
|
188
211
|
if (Array.isArray(rows) && rows.every(row => row && Number.isFinite(row.time) && ["usage", "duration", "issue"].includes(row.kind))) recordSources[key] = rows;
|
|
189
212
|
}
|
|
190
213
|
const result = { latest: bucket(), total: bucket(), subagents: sessions.length - 1, pricingDate: pricing.date,
|
|
191
|
-
durationMs: 0, durationTurns: 0, partial: inventoryFailed };
|
|
214
|
+
durationMs: 0, durationTurns: 0, timeline: [], analyticsPartial: inventoryFailed, partial: inventoryFailed };
|
|
192
215
|
for (const { session, records, failed, latestFailed, timePartial } of parsed) {
|
|
193
216
|
const summary = summarizeRecords(records, latestTurnId, pricing.models, session.id !== id);
|
|
194
217
|
const retained = missingSources.filter(key => JSON.parse(key)[0] === session.id).flatMap(key => recordSources[key] ?? []);
|
|
@@ -202,7 +225,10 @@ export class CodexSessionUsage {
|
|
|
202
225
|
summary.latest.partial ||= missing.latest.samples > 0 || missing.latest.partial;
|
|
203
226
|
summary.latest.costPartial ||= missing.latest.samples > 0 || missing.latest.costPartial;
|
|
204
227
|
summary.durations = combined.durations;
|
|
228
|
+
summary.timeline = combined.timeline;
|
|
205
229
|
}
|
|
230
|
+
result.analyticsPartial ||= timePartial || summary.total.tokens.total_tokens !== summary.timeline.reduce((sum, sample) => sum + sample.tokens.total_tokens, 0);
|
|
231
|
+
result.timeline.push(...summary.timeline);
|
|
206
232
|
for (const key of ["total", "latest"]) {
|
|
207
233
|
add(result[key].tokens, summary[key].tokens);
|
|
208
234
|
result[key].costUsd += summary[key].costUsd;
|
|
@@ -221,6 +247,7 @@ export class CodexSessionUsage {
|
|
|
221
247
|
}
|
|
222
248
|
}
|
|
223
249
|
if (missingSources.length) {
|
|
250
|
+
result.analyticsPartial = true;
|
|
224
251
|
result.total.partial = result.total.costPartial = true;
|
|
225
252
|
const prior = saved?.result.total;
|
|
226
253
|
if (prior) {
|
|
@@ -240,22 +267,29 @@ export class CodexSessionUsage {
|
|
|
240
267
|
}
|
|
241
268
|
if (missingSources.some(key => JSON.parse(key)[0] === id)) result.partial = true;
|
|
242
269
|
}
|
|
243
|
-
const entry = { version:
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
if (cachePath && !inventoryFailed
|
|
270
|
+
const entry = { version: 3, sourceSignature, result, recordSources, sourceFiles, retryRead, knownSources: [...new Set([...knownSources, ...currentSources])] };
|
|
271
|
+
// Keep successful sources even if another source failed. Retry failures on
|
|
272
|
+
// the next read without reparsing all healthy historical files.
|
|
273
|
+
this.results.set(resultKey, entry);
|
|
274
|
+
if (cachePath && !inventoryFailed) {
|
|
248
275
|
try { await writeDurableAtomic(cachePath, JSON.stringify(entry) + "\n", { mode: 0o600 }); }
|
|
249
276
|
catch { /* Cache write failures do not hide readable source usage. */ }
|
|
250
277
|
}
|
|
251
278
|
return result;
|
|
252
279
|
}
|
|
280
|
+
sessionPaths(id) {
|
|
281
|
+
// Production chat IDs are UUIDs. Index once instead of walking every filename
|
|
282
|
+
// for every root and descendant on each refresh.
|
|
283
|
+
return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(id)
|
|
284
|
+
? this.pathsBySession.get(id) ?? []
|
|
285
|
+
: this.paths.filter(path => basename(path).includes(`-${id}`));
|
|
286
|
+
}
|
|
253
287
|
async readFile(path, expectedId) {
|
|
254
288
|
const source = await openRollout(path);
|
|
255
289
|
try {
|
|
256
290
|
path = plainRolloutPath(path);
|
|
257
291
|
const { info } = source;
|
|
258
|
-
if (!info.isFile() || info.size === 0) throw new Error("Rollout is empty or not a file.");
|
|
292
|
+
if (!info.isFile() || info.size === 0) throw Object.assign(new Error("Rollout is empty or not a file."), { code: "INVALID_ROLLOUT" });
|
|
259
293
|
let cached = this.files.get(path);
|
|
260
294
|
if (cached && cached.identity === `${info.dev}:${info.ino}` && cached.size === info.size && cached.mtime === info.mtimeMs) return this.sourceView(cached, expectedId);
|
|
261
295
|
const compressed = source.path.endsWith(".zst");
|
|
@@ -283,7 +317,7 @@ export class CodexSessionUsage {
|
|
|
283
317
|
cached.size = info.size; cached.mtime = info.mtimeMs;
|
|
284
318
|
cached.tail = pending; cached.skipping = skipping;
|
|
285
319
|
const view = this.sourceView(cached, expectedId);
|
|
286
|
-
if (!view.valid) throw new Error("Rollout belongs to a different chat.");
|
|
320
|
+
if (!view.valid) throw Object.assign(new Error("Rollout belongs to a different chat."), { code: "INVALID_ROLLOUT" });
|
|
287
321
|
this.files.set(path, cached);
|
|
288
322
|
return view;
|
|
289
323
|
} finally { await source.handle.close(); }
|