taskchef 9.3.0 → 9.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/.codex-plugin/plugin.json +1 -1
- package/docs/mcp-app.md +88 -5
- package/package.json +1 -1
- package/src/calendar-history.js +30 -0
- package/src/codex-rollout.js +3 -3
- package/src/codex-session-scanner.js +61 -4
- package/src/codex-session-usage.js +54 -20
- package/src/mcp-app/dist/index.html +145 -138
- package/src/mcp-app/react/CalendarView.test.tsx +59 -0
- package/src/mcp-app/react/CalendarView.tsx +91 -0
- package/src/mcp-app/react/UsageView.test.tsx +124 -0
- package/src/mcp-app/react/UsageView.tsx +129 -0
- package/src/mcp-app/react/main.test.tsx +137 -2
- package/src/mcp-app/react/main.tsx +29 -15
- package/src/mcp-app/react/preferences.ts +34 -0
- package/src/mcp-app/react/styles.css +86 -11
- package/src/mcp-app.js +46 -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
|
|
@@ -300,7 +299,7 @@ UI surfaces. Native font families use locally available fonts with system fallba
|
|
|
300
299
|
read the installed desktop application or fetch theme files from the network.
|
|
301
300
|
|
|
302
301
|
The compact **Pinned** toggle button shows chats pinned in Codex and combines with project,
|
|
303
|
-
update window, and search filters. It
|
|
302
|
+
update window, and search filters. It defaults to off and remembers the user’s selection locally. Pin
|
|
304
303
|
changes appear on the next data refresh. The current desktop pin list takes
|
|
305
304
|
precedence over the database `is_pinned` field. TaskChef ignores old migration
|
|
306
305
|
snapshots and never writes pin state. Without either source, chats are unpinned.
|
|
@@ -365,3 +364,87 @@ 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 (through today), and
|
|
375
|
+
Last month (the full previous calendar month). The default is 30 days; the
|
|
376
|
+
selected range is remembered locally when Usage is reopened.
|
|
377
|
+
There is no custom range. Dates use the view's local timezone, including DST.
|
|
378
|
+
Range changes and reopening Usage query one shared history snapshot without
|
|
379
|
+
rescanning logs. The snapshot is scoped by CLI/exec visibility, independently of
|
|
380
|
+
range or timezone. After one minute it refreshes in the background while cached
|
|
381
|
+
charts remain visible. Refresh explicitly rebuilds it, reusing existing per-file
|
|
382
|
+
metadata and incremental parsing caches. Unchanged files retain parsed records
|
|
383
|
+
on disk across restarts and turn or price changes. A session filename index
|
|
384
|
+
avoids searching the full inventory for each chat. Missing paths do not incur
|
|
385
|
+
reader retry delays. Empty or mismatched files are checked again when their
|
|
386
|
+
metadata changes; transient I/O failures retry while retaining healthy sources.
|
|
387
|
+
Rescan explicitly reparses available files and refreshes the inventory, while
|
|
388
|
+
retaining known history for missing sources. Board polling never starts a history scan.
|
|
389
|
+
|
|
390
|
+
The combined chart uses cost bars on the left axis and a token line on the
|
|
391
|
+
right. Today's cost bars have a lighter fill and dashed borders in both charts;
|
|
392
|
+
the token segment leading into today is dashed too. Finished days, including
|
|
393
|
+
the last day of Last month, stay solid. The project chart uses stacked cost bars for the period's top five
|
|
394
|
+
projects plus Other. Select a legend entry or a Top 10 row to isolate that
|
|
395
|
+
project in the project chart; All projects restores the stack. Hovering or
|
|
396
|
+
focusing a legend entry highlights its bars and dims other project colors. Totals, the
|
|
397
|
+
combined chart, and rankings always describe the full selected period.
|
|
398
|
+
|
|
399
|
+
Archived chats and descendant usage are included. CLI/exec visibility settings
|
|
400
|
+
still apply to root chats. Descendants are counted once under their root chat's
|
|
401
|
+
current project. Unassigned chats share one Unassigned project. Daily usage
|
|
402
|
+
comes from recorded call timestamps, not chat creation or last-update dates.
|
|
403
|
+
Unexplained cumulative counter gaps stay in lifetime totals but are excluded
|
|
404
|
+
from daily totals because their dates are unknown. Missing records or prices
|
|
405
|
+
remain marked in the analytics response without displaying coverage warnings.
|
|
406
|
+
Unknown costs are excluded from estimated cost.
|
|
407
|
+
Cached input is part of input tokens, and reasoning output is part of output.
|
|
408
|
+
Cost is an estimate at current API-equivalent rates, not subscription billing
|
|
409
|
+
or remaining allowance. The first read shows a progress bar with the actual number of root chats
|
|
410
|
+
processed and polls an app-only tool. Cached charts stay visible during later
|
|
411
|
+
refreshes, with their last-update time and scan progress.
|
|
412
|
+
|
|
413
|
+
## Month calendar
|
|
414
|
+
|
|
415
|
+
Calendar sits beside Board and Usage. It opens on the current local month;
|
|
416
|
+
previous/next arrows surround the month title. Next is disabled at the current
|
|
417
|
+
local month, so future months cannot be selected. There is no Today control.
|
|
418
|
+
Arrows stay hidden until the month navigation is hovered or contains focus;
|
|
419
|
+
a clicked arrow remains visible while focused. This also applies in embedded
|
|
420
|
+
hosts that do not report mouse hover support. Project, search, update window, and Pinned
|
|
421
|
+
filters are remembered locally. An unavailable saved project resets to All
|
|
422
|
+
projects only after the project inventory loads.
|
|
423
|
+
Project, Search Chats, Pinned, and native visibility settings apply. The Board's
|
|
424
|
+
Updated filter does not apply to Calendar. A Calendar-only Scheduled
|
|
425
|
+
toggle starts enabled and hides chats with an active schedule when disabled,
|
|
426
|
+
including those currently Running or Waiting. Its choice is remembered locally across page visits and does not filter
|
|
427
|
+
Board or Usage. Selecting one project hides
|
|
428
|
+
project prefixes on calendar bars. The grid has no persistent helper captions. Scheduled and Pinned share
|
|
429
|
+
the same toggle styling for their on, off, and hover states.
|
|
430
|
+
|
|
431
|
+
Each chat's recorded turn intervals become calendar dates in the viewer's
|
|
432
|
+
timezone. Adjacent dates merge into one bar. One full idle date breaks the bar,
|
|
433
|
+
including weekends; a later segment has a resumed marker and opens the same
|
|
434
|
+
chat Details. Continuous work across a week boundary uses continuation arrows.
|
|
435
|
+
An exact midnight end does not occupy the following date. A current running
|
|
436
|
+
turn extends to now; completed turns do not extend through idle review waits.
|
|
437
|
+
Project colors stay consistent when navigating or filtering. Crowded weeks show
|
|
438
|
+
three bar rows and a control to expand the remaining chats. Narrow views scroll
|
|
439
|
+
horizontally to retain seven readable day columns.
|
|
440
|
+
|
|
441
|
+
The calendar reads all turn timing rows for each eligible chat's selected
|
|
442
|
+
SQLite history, cached until the databases change. The calendar rereads on
|
|
443
|
+
changed snapshots or database reads, explicit Refresh, and local date rollover;
|
|
444
|
+
an unchanged five-second poll does not reread calendar history. It does not scan usage,
|
|
445
|
+
modify Codex records, or create editable start/end dates. Earlier histories
|
|
446
|
+
replaced by a rewind may be absent. Missing start timestamps are omitted;
|
|
447
|
+
missing durations show only a known start date. Missing timing data does not
|
|
448
|
+
add a notice to the calendar.
|
|
449
|
+
|
|
450
|
+

|
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Date-only arithmetic uses UTC; recorded instants are first converted to the
|
|
2
|
+
// viewer's timezone. This keeps full idle days consistent across DST changes.
|
|
3
|
+
const nextDay = date => new Date(Date.parse(`${date}T00:00:00Z`) + 86400000).toISOString().slice(0, 10);
|
|
4
|
+
export function calendarSegments(turns, { month, timeZone, now = Date.now(), latestTurnId }) {
|
|
5
|
+
const format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
6
|
+
const day = time => {
|
|
7
|
+
const parts = Object.fromEntries(format.formatToParts(time).map(part => [part.type, part.value]));
|
|
8
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
9
|
+
};
|
|
10
|
+
let incomplete = false;
|
|
11
|
+
const intervals = [];
|
|
12
|
+
for (const turn of turns) {
|
|
13
|
+
const start = turn.started_at * 1000;
|
|
14
|
+
if (!Number.isFinite(start) || turn.started_at == null || start <= 0 || start > now) { incomplete = true; continue; }
|
|
15
|
+
const running = turn.status === 'inProgress' && turn.turn_id === latestTurnId;
|
|
16
|
+
const hasDuration = Number.isFinite(turn.duration_ms) && turn.duration_ms >= 0;
|
|
17
|
+
if (!running && !hasDuration) incomplete = true;
|
|
18
|
+
const end = running ? now : Math.min(now, start + (hasDuration ? turn.duration_ms : 0));
|
|
19
|
+
// A turn ending exactly at midnight did not work on the following day.
|
|
20
|
+
intervals.push({ start: day(start), end: day(Math.max(start, end - 1)) });
|
|
21
|
+
}
|
|
22
|
+
intervals.sort((a, b) => a.start.localeCompare(b.start) || a.end.localeCompare(b.end));
|
|
23
|
+
const segments = [];
|
|
24
|
+
for (const interval of intervals) {
|
|
25
|
+
const previous = segments.at(-1);
|
|
26
|
+
if (previous && interval.start <= nextDay(previous.end)) previous.end = previous.end > interval.end ? previous.end : interval.end;
|
|
27
|
+
else segments.push({ ...interval, resumed: segments.length > 0 });
|
|
28
|
+
}
|
|
29
|
+
return { incomplete, segments: segments.filter(segment => segment.start.slice(0, 7) <= month && segment.end.slice(0, 7) >= month) };
|
|
30
|
+
}
|
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,5 @@
|
|
|
1
|
+
import { calendarSegments } from "./calendar-history.js";
|
|
2
|
+
import { aggregateUsage } from "./usage-analytics.js";
|
|
1
3
|
import { openRollout, rolloutChunks } from "./codex-rollout.js";
|
|
2
4
|
import { createHash } from "node:crypto";
|
|
3
5
|
import { normalizeUri } from "micromark-util-sanitize-uri";
|
|
@@ -239,9 +241,10 @@ function readDatabaseRecords(state, history) {
|
|
|
239
241
|
FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal DESC LIMIT 1`);
|
|
240
242
|
const turns = new Map();
|
|
241
243
|
const needsAnswers = new Map();
|
|
242
|
-
const
|
|
244
|
+
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
245
|
(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()
|
|
246
|
+
FROM threads WHERE ${eligible} ORDER BY recency_at_ms DESC`).all();
|
|
247
|
+
const rows = allRows.filter((row) => {
|
|
245
248
|
const key = historyId(row);
|
|
246
249
|
const turn = latestTurn.get(key);
|
|
247
250
|
if (!turn) return false;
|
|
@@ -285,7 +288,7 @@ function readDatabaseRecords(state, history) {
|
|
|
285
288
|
attachments.set(row.thread_id, urls);
|
|
286
289
|
}
|
|
287
290
|
}
|
|
288
|
-
return { rows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
|
|
291
|
+
return { rows, allRows, turns, inputs, replies, projects, attachments, turnAttachments, needsAnswers };
|
|
289
292
|
}
|
|
290
293
|
|
|
291
294
|
function buildDatabaseSnapshot(records, now, doneMarks, schedules, scheduleErrors, cacheHit) {
|
|
@@ -649,6 +652,60 @@ export class CodexSessionScanner {
|
|
|
649
652
|
) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(id);
|
|
650
653
|
return this.sessionUsage.read({ id, latestTurnId: task.turnId, sessions, savedDurations });
|
|
651
654
|
}
|
|
655
|
+
async calendarHistory({ month, timeZone, settings }) {
|
|
656
|
+
const snapshot = await this.refresh();
|
|
657
|
+
if (!snapshot.healthy) throw new Error(snapshot.scan.error);
|
|
658
|
+
const records = this.databaseCache.records;
|
|
659
|
+
const cache = records.calendarTurns ??= new Map();
|
|
660
|
+
const rows = new Map(records.rows.map(row => [row.id, row]));
|
|
661
|
+
const query = this.connections.history.prepare(`SELECT turn_id, started_at, duration_ms, status
|
|
662
|
+
FROM thread_turns WHERE thread_id = ? ORDER BY rollout_ordinal`);
|
|
663
|
+
const chats = [];
|
|
664
|
+
for (const task of snapshot.tasks) {
|
|
665
|
+
if ((!settings.showArchived && task.observed?.archive) ||
|
|
666
|
+
(!settings.showCli && task.observed?.source === "cli") ||
|
|
667
|
+
(!settings.showExec && task.observed?.source === "exec")) continue;
|
|
668
|
+
const row = rows.get(task.id);
|
|
669
|
+
let turns = cache.get(task.id);
|
|
670
|
+
if (!turns) { turns = query.all(historyId(row)); cache.set(task.id, turns); }
|
|
671
|
+
const result = calendarSegments(turns, { month, timeZone, now: this.now(), latestTurnId: task.turnId });
|
|
672
|
+
if (result.segments.length || result.incomplete) chats.push({ id: task.id, ...result });
|
|
673
|
+
}
|
|
674
|
+
return { month, timeZone, chats };
|
|
675
|
+
}
|
|
676
|
+
async usageAnalytics({ range, timeZone, settings }) {
|
|
677
|
+
return aggregateUsage(await this.usageHistory({ settings }), { range, timeZone, now: this.now() });
|
|
678
|
+
}
|
|
679
|
+
async usageHistory({ settings, onProgress = () => {}, rescan = false }) {
|
|
680
|
+
if (rescan) this.sessionUsage.inventoryAt = -Infinity;
|
|
681
|
+
const snapshot = await this.refresh();
|
|
682
|
+
if (!snapshot.healthy) throw new Error(snapshot.scan.error);
|
|
683
|
+
const records = this.databaseCache.records;
|
|
684
|
+
// Analytics inventories every root, even if its latest turn is absent from
|
|
685
|
+
// the board's history database. Its rollout can still hold valid usage.
|
|
686
|
+
const grouped = await this.projectCatalog.group(records.allRows.map(row => ({
|
|
687
|
+
id: row.id, turnId: records.turns.get(historyId(row))?.turn_id ?? null,
|
|
688
|
+
project: { id: row.project_id, path: row.cwd || "", githubRepos: [] }, observed: { source: row.source },
|
|
689
|
+
})), records.projects);
|
|
690
|
+
const families = [];
|
|
691
|
+
const seen = new Set();
|
|
692
|
+
// History includes archived chats. CLI/exec preferences still apply.
|
|
693
|
+
const tasks = grouped.tasks.filter(task => (settings.showCli || task.observed?.source !== "cli") && (settings.showExec || task.observed?.source !== "exec"));
|
|
694
|
+
let completed = 0;
|
|
695
|
+
onProgress({ completed, total: tasks.length });
|
|
696
|
+
for (const task of tasks) {
|
|
697
|
+
const sessions = this.connections.state.prepare(`WITH RECURSIVE family(id) AS (
|
|
698
|
+
SELECT ? UNION SELECT child_thread_id FROM thread_spawn_edges JOIN family ON parent_thread_id = family.id
|
|
699
|
+
) SELECT id, rollout_path FROM threads WHERE id IN (SELECT id FROM family)`).all(task.id).filter(row => !seen.has(row.id));
|
|
700
|
+
if (sessions.length) {
|
|
701
|
+
sessions.forEach(row => seen.add(row.id));
|
|
702
|
+
const usage = await this.sessionUsage.read({ id: task.id, latestTurnId: task.turnId, sessions, rescan });
|
|
703
|
+
families.push({ project: task.project, usage });
|
|
704
|
+
}
|
|
705
|
+
onProgress({ completed: ++completed, total: tasks.length });
|
|
706
|
+
}
|
|
707
|
+
return families;
|
|
708
|
+
}
|
|
652
709
|
recordedUsage(id, turnId) {
|
|
653
710
|
const result = (this.recordedPending ?? Promise.resolve()).then(() => this.readRecordedUsage(id, turnId));
|
|
654
711
|
this.recordedPending = result.catch(() => {});
|
|
@@ -711,7 +768,7 @@ export class CodexSessionScanner {
|
|
|
711
768
|
let detail = { ...task, ...records.latestContent.get(id), detailPullRequests, observed: { ...task.observed,
|
|
712
769
|
recordedChatDurationMs: durations.total ?? 0, missingTurnDurations: durations.missing ?? 0 } };
|
|
713
770
|
try {
|
|
714
|
-
const sessionUsage = await this.taskUsage(id, durations.saved);
|
|
771
|
+
const { timeline: _timeline, analyticsPartial: _analyticsPartial, ...sessionUsage } = await this.taskUsage(id, durations.saved);
|
|
715
772
|
detail = { ...detail, sessionUsage, observed: { ...detail.observed,
|
|
716
773
|
...(sessionUsage.durationTurns ? { recordedChatDurationMs: Math.max(durations.total ?? 0, sessionUsage.durationMs) } : {}),
|
|
717
774
|
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(); }
|