mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): count daemon sessions in Daemon Status usage dashboard (#6493)
* fix(web-shell): count daemon sessions in Daemon Status usage dashboard
The Web Shell usage dashboard read usage_record.jsonl exclusively, but only
the TUI /clear path ever writes that file — so daemon / Web Shell sessions and
any un-cleared session (whose usage lives only in the per-session transcripts)
were never counted. Real-world "today" totals could undercount ~20x.
Add core loadUsageHistoryWithLive(): the durable persisted history unioned with
a bounded replay of recent transcripts, deduped by sessionId (persisted wins,
as the authoritative final snapshot). rebuildFromSessionJsonl gains an mtime
window and a skip-set (read from each transcript's first line) so the merge is
incremental and cheap. The daemon /usage/dashboard route and loadUsageDashboard
now use it.
The trailing window (35d) keeps the summary + daily charts exact while bounding
load latency (~1.7s vs ~13s for a full-year replay on a heavy history); older
heatmap cells fall back to persisted data. With no persisted base (fresh
machine / pure Web Shell user) it replays the full history, so the heatmap is
never silently truncated. Read-only: serving the dashboard never writes ~/.qwen.
* fix(web-shell): address review — skip transcripts by filename, add coverage
Skip already-persisted transcripts by their filename (`{sessionId}.jsonl`,
guaranteed by chatRecordingService) instead of opening each file to read the
sessionId from its first line — zero I/O per skipped session on a cache miss.
Add tests: the loadUsageDashboard wiring counts a transcript-only (daemon)
session, the rebuilt-empty (all-persisted) common case, and a corrupt
usage_record.jsonl falling back to a full transcript replay.
This commit is contained in:
parent
045bbee6ce
commit
29cefd7fb1
5 changed files with 385 additions and 22 deletions
|
|
@ -8,16 +8,17 @@
|
|||
* Read-only usage-dashboard surface behind the Web Shell Daemon Status
|
||||
* "统计 / Usage" tab. Serves the selected range's (`today`/`week`/`month`)
|
||||
* flattened token totals plus a trailing per-day heatmap, computed by core's
|
||||
* `buildUsageDashboard` from the durable local usage history (global `~/.qwen`,
|
||||
* cross-project) — the same source the TUI `/stats` command reads. The history
|
||||
* load runs read-only (`persistRebuild: false`), so serving a GET never writes
|
||||
* to `~/.qwen`, even when the persisted file is missing and the loader falls
|
||||
* back to transcript replay.
|
||||
* `buildUsageDashboard` from the local usage history (global `~/.qwen`,
|
||||
* cross-project). Uses `loadUsageHistoryWithLive`, which unions the durable
|
||||
* `usage_record.jsonl` (written only by the TUI `/clear` path) with a replay of
|
||||
* recent transcripts — so daemon / Web Shell sessions and any in-progress
|
||||
* session are counted here, unlike the TUI `/stats` view. The load is
|
||||
* read-only (never writes `~/.qwen`).
|
||||
*
|
||||
* Open GET (no `mutate` gate), consistent with `GET /daemon/status` and
|
||||
* `GET /scheduled-tasks`: it exposes only aggregate local usage counts.
|
||||
*
|
||||
* The heavy step — `loadUsageHistory` can replay every project's transcripts —
|
||||
* The heavy step — `loadUsageHistoryWithLive` can replay recent transcripts —
|
||||
* is cached once (range-independent), so toggling Today/7D/30D re-aggregates
|
||||
* cheaply from a single disk read instead of re-loading per range, and
|
||||
* concurrent requests coalesce onto the in-flight load.
|
||||
|
|
@ -26,7 +27,7 @@
|
|||
import type { Application } from 'express';
|
||||
import {
|
||||
buildUsageDashboard,
|
||||
loadUsageHistory,
|
||||
loadUsageHistoryWithLive,
|
||||
type UsageSummaryRecord,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { writeStderrLine } from '../../utils/stdioHelpers.js';
|
||||
|
|
@ -67,9 +68,7 @@ export function registerUsageStatsRoutes(
|
|||
app: Application,
|
||||
deps: RegisterUsageStatsRoutesDeps = {},
|
||||
): void {
|
||||
const loadHistory =
|
||||
deps.loadHistory ??
|
||||
(() => loadUsageHistory(undefined, { persistRebuild: false }));
|
||||
const loadHistory = deps.loadHistory ?? (() => loadUsageHistoryWithLive());
|
||||
const ttlMs = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
||||
|
||||
// Closure-scoped, range-independent history cache (one daemon per process).
|
||||
|
|
|
|||
|
|
@ -98,6 +98,89 @@ describe('loadUsageDashboard', () => {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plant a transcript-only (never-persisted, e.g. daemon / Web Shell) session
|
||||
* timestamped now, so it lands in the `today` window when replayed.
|
||||
*/
|
||||
function plantTranscript(sessionId: string, totalTokens: number): void {
|
||||
const cwd = '/daemon/project';
|
||||
const ts = new Date().toISOString();
|
||||
const chatsDir = path.join(
|
||||
process.env['QWEN_HOME']!,
|
||||
'projects',
|
||||
'daemon-project',
|
||||
'chats',
|
||||
);
|
||||
fs.mkdirSync(chatsDir, { recursive: true });
|
||||
const records = [
|
||||
{
|
||||
sessionId,
|
||||
cwd,
|
||||
uuid: 'u1',
|
||||
parentUuid: null,
|
||||
timestamp: ts,
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'hi' },
|
||||
},
|
||||
{
|
||||
sessionId,
|
||||
cwd,
|
||||
uuid: 'u2',
|
||||
parentUuid: 'u1',
|
||||
timestamp: ts,
|
||||
type: 'system',
|
||||
subtype: 'ui_telemetry',
|
||||
systemPayload: {
|
||||
uiEvent: {
|
||||
'event.name': 'qwen-code.api_response',
|
||||
'event.timestamp': ts,
|
||||
response_id: 'r1',
|
||||
model: 'qwen-max',
|
||||
duration_ms: 1200,
|
||||
input_token_count: totalTokens * 0.6,
|
||||
output_token_count: totalTokens * 0.4,
|
||||
cached_content_token_count: 0,
|
||||
thoughts_token_count: 0,
|
||||
total_token_count: totalTokens,
|
||||
prompt_id: 'p1',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId,
|
||||
cwd,
|
||||
uuid: 'u3',
|
||||
parentUuid: 'u2',
|
||||
timestamp: ts,
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
},
|
||||
];
|
||||
fs.writeFileSync(
|
||||
path.join(chatsDir, `${sessionId}.jsonl`),
|
||||
records.map((r) => JSON.stringify(r)).join('\n') + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
it('today totals include a daemon / Web Shell transcript session, not just the persisted file', async () => {
|
||||
// Guards the wiring: loadUsageDashboard must read the live-merging loader,
|
||||
// so a session that only lives in a transcript (never persisted by /clear)
|
||||
// still counts. A revert to the persisted-only loader would drop it.
|
||||
seed([
|
||||
rec({
|
||||
sessionId: 'persisted',
|
||||
timestamp: Date.now(),
|
||||
model: { totalTokens: 1000, inputTokens: 1000 },
|
||||
}),
|
||||
]);
|
||||
plantTranscript('daemon-live', 1600);
|
||||
|
||||
const dash = await loadUsageDashboard({ range: 'today' });
|
||||
|
||||
expect(dash.summary.sessions).toBe(2);
|
||||
expect(dash.summary.totalTokens).toBe(2600);
|
||||
});
|
||||
|
||||
it('flattens today totals across all sessions active today', async () => {
|
||||
const now = Date.now();
|
||||
seed([
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import {
|
||||
aggregateUsage,
|
||||
getTimeRangeBounds,
|
||||
loadUsageHistory,
|
||||
loadUsageHistoryWithLive,
|
||||
type TimeRange,
|
||||
type UsageSummaryRecord,
|
||||
} from './usageHistoryService.js';
|
||||
|
|
@ -275,14 +275,15 @@ export function buildUsageDashboard(
|
|||
|
||||
/**
|
||||
* Read-only snapshot of local token usage for the daemon usage-dashboard API.
|
||||
* Loads the global cross-project history (`~/.qwen`) via {@link loadUsageHistory}
|
||||
* (persisted `usage_record.jsonl`, falling back to transcript replay) and builds
|
||||
* the dashboard — consistent with the TUI `/stats` view. The load can be I/O
|
||||
* heavy on large histories, so callers should cache (the daemon route caches the
|
||||
* loaded records and re-runs {@link buildUsageDashboard} per range).
|
||||
* Loads the global cross-project history (`~/.qwen`) via
|
||||
* {@link loadUsageHistoryWithLive} — the persisted `usage_record.jsonl` unioned
|
||||
* with a replay of recent transcripts — so the totals include daemon / Web Shell
|
||||
* and in-progress sessions that the persisted file never captures. The load can
|
||||
* be I/O heavy on large histories, so callers should cache (the daemon route
|
||||
* caches the loaded records and re-runs {@link buildUsageDashboard} per range).
|
||||
*/
|
||||
export async function loadUsageDashboard(
|
||||
options: LoadUsageDashboardOptions = {},
|
||||
): Promise<UsageDashboard> {
|
||||
return buildUsageDashboard(await loadUsageHistory(), options);
|
||||
return buildUsageDashboard(await loadUsageHistoryWithLive(), options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
metricsToUsageRecord,
|
||||
aggregateUsage,
|
||||
loadUsageHistory,
|
||||
loadUsageHistoryWithLive,
|
||||
persistSessionUsage,
|
||||
} from './usageHistoryService.js';
|
||||
import { ToolCallDecision } from '../telemetry/tool-call-decision.js';
|
||||
|
|
@ -626,6 +627,154 @@ describe('loadUsageHistory + persistSessionUsage (issue #4994 regression)', () =
|
|||
for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
|
||||
expect(totalTokens).toBe(1600);
|
||||
});
|
||||
|
||||
// loadUsageHistoryWithLive: the daemon usage-dashboard loader. Unlike
|
||||
// loadUsageHistory (persisted file verbatim when non-empty), it unions the
|
||||
// persisted history with a replay of recent transcripts so daemon / Web Shell
|
||||
// and in-progress sessions — which only the TUI /clear path ever persists —
|
||||
// are counted. See issue: Web Shell "today" undercounted ~20x.
|
||||
function planted(sessionId: string): string {
|
||||
return path.join(
|
||||
process.env['QWEN_HOME']!,
|
||||
'projects',
|
||||
'repro-project',
|
||||
'chats',
|
||||
`${sessionId}.jsonl`,
|
||||
);
|
||||
}
|
||||
function seedPersisted(records: UsageSummaryRecord[]) {
|
||||
fs.writeFileSync(
|
||||
path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
|
||||
records.map((r) => JSON.stringify(r)).join('\n') + '\n',
|
||||
);
|
||||
}
|
||||
function persistedRec(sessionId: string, totalTokens: number) {
|
||||
return {
|
||||
version: 1 as const,
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
startTime: Date.now() - 60000,
|
||||
project: '/p',
|
||||
durationMs: 60000,
|
||||
totalLatencyMs: 1200,
|
||||
models: {
|
||||
'qwen-max': {
|
||||
requests: 1,
|
||||
inputTokens: totalTokens,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
thoughtsTokens: 0,
|
||||
totalTokens,
|
||||
},
|
||||
},
|
||||
tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, byName: {} },
|
||||
files: { linesAdded: 0, linesRemoved: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
it('withLive: unions a never-persisted (daemon) session with the persisted history', async () => {
|
||||
// Persisted: a finalized TUI session. Transcript-only: a daemon / Web Shell
|
||||
// session that /clear never wrote to usage_record.jsonl.
|
||||
seedPersisted([persistedRec('sess-persisted', 1000)]);
|
||||
plantChatJsonl('sess-daemon', 1600);
|
||||
|
||||
const merged = await loadUsageHistoryWithLive();
|
||||
const ids = merged.map((r) => r.sessionId).sort();
|
||||
expect(ids).toEqual(['sess-daemon', 'sess-persisted']);
|
||||
|
||||
const report = aggregateUsage(merged, 'all');
|
||||
let totalTokens = 0;
|
||||
for (const m of Object.values(report.models)) totalTokens += m.totalTokens;
|
||||
// 1000 persisted + 1600 replayed from the daemon transcript.
|
||||
expect(totalTokens).toBe(2600);
|
||||
|
||||
// Read-only: the persisted file must still hold only the original record.
|
||||
const lines = fs
|
||||
.readFileSync(
|
||||
path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
|
||||
'utf8',
|
||||
)
|
||||
.trim()
|
||||
.split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('withLive: a persisted session with a live transcript is not double-counted (persisted wins)', async () => {
|
||||
// Same sessionId in both the persisted file (authoritative 1000) and the
|
||||
// transcript (1600). Must appear exactly once, keeping the persisted value.
|
||||
seedPersisted([persistedRec('sess-both', 1000)]);
|
||||
plantChatJsonl('sess-both', 1600);
|
||||
|
||||
const merged = await loadUsageHistoryWithLive();
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]!.sessionId).toBe('sess-both');
|
||||
expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('withLive: with a persisted base, the trailing window excludes stale transcripts (sinceMs:0 includes them)', async () => {
|
||||
// A persisted base means the window engages (old days come from the file).
|
||||
seedPersisted([persistedRec('sess-persisted', 500)]);
|
||||
plantChatJsonl('sess-old', 1600);
|
||||
// Age the never-persisted transcript well past the default trailing window.
|
||||
const stale = Date.now() - 100 * 24 * 60 * 60 * 1000;
|
||||
fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000);
|
||||
|
||||
// Default window: the stale, never-persisted transcript is not replayed.
|
||||
const windowed = await loadUsageHistoryWithLive();
|
||||
expect(windowed.map((r) => r.sessionId)).toEqual(['sess-persisted']);
|
||||
// An unbounded window picks it back up alongside the persisted record.
|
||||
const all = await loadUsageHistoryWithLive({ sinceMs: 0 });
|
||||
expect(all.map((r) => r.sessionId).sort()).toEqual([
|
||||
'sess-old',
|
||||
'sess-persisted',
|
||||
]);
|
||||
});
|
||||
|
||||
it('withLive: with no persisted base, replays full history (no silent trailing-window truncation)', async () => {
|
||||
// No usage_record.jsonl: nothing else covers old history, so an old
|
||||
// transcript must still be replayed rather than truncated by the window.
|
||||
plantChatJsonl('sess-old', 1600);
|
||||
const stale = Date.now() - 100 * 24 * 60 * 60 * 1000;
|
||||
fs.utimesSync(planted('sess-old'), stale / 1000, stale / 1000);
|
||||
|
||||
const merged = await loadUsageHistoryWithLive();
|
||||
expect(merged.map((r) => r.sessionId)).toEqual(['sess-old']);
|
||||
});
|
||||
|
||||
it('withLive: read-only rebuild — never writes usage_record.jsonl', async () => {
|
||||
plantChatJsonl('sess-daemon-only', 1600);
|
||||
const usagePath = path.join(
|
||||
process.env['QWEN_HOME']!,
|
||||
'usage_record.jsonl',
|
||||
);
|
||||
|
||||
const merged = await loadUsageHistoryWithLive();
|
||||
expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon-only']);
|
||||
expect(fs.existsSync(usagePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('withLive: all sessions persisted (empty rebuild) returns the persisted records as-is', async () => {
|
||||
// Common case: no live transcripts at all, only the persisted file.
|
||||
seedPersisted([persistedRec('sess-only', 500)]);
|
||||
|
||||
const merged = await loadUsageHistoryWithLive();
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]!.sessionId).toBe('sess-only');
|
||||
expect(merged[0]!.models['qwen-max']!.totalTokens).toBe(500);
|
||||
});
|
||||
|
||||
it('withLive: a corrupt usage_record.jsonl falls back to a full transcript replay', async () => {
|
||||
// No usable persisted base (garbage file) — the loader must still surface
|
||||
// the daemon transcript rather than returning nothing.
|
||||
fs.writeFileSync(
|
||||
path.join(process.env['QWEN_HOME']!, 'usage_record.jsonl'),
|
||||
'{ this is not valid json\nalso broken}\n',
|
||||
);
|
||||
plantChatJsonl('sess-daemon', 1600);
|
||||
|
||||
const merged = await loadUsageHistoryWithLive();
|
||||
expect(merged.map((r) => r.sessionId)).toEqual(['sess-daemon']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateUsage — skills', () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@ import { createDebugLogger } from '../utils/debugLogger.js';
|
|||
|
||||
const debugLogger = createDebugLogger('USAGE_HISTORY');
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Trailing window used by {@link loadUsageHistoryWithLive} when merging
|
||||
* non-persisted (daemon / Web Shell / in-progress) sessions into the history.
|
||||
*
|
||||
* Sized to cover the largest summary/daily range the usage-dashboard exposes
|
||||
* (month = 30 days) plus margin, so the hero totals, breakdown tiles, and the
|
||||
* per-day line/bar charts are exact. Crucially it is NOT sized for the full
|
||||
* heatmap span (~12 months): persisted `usage_record.jsonl` records of any age
|
||||
* are always unioned in, so the heatmap keeps its full history, but a *never-
|
||||
* persisted* daemon session older than this window is not replayed — that cell
|
||||
* undercounts slightly. That cosmetic gap is a deliberate trade for load
|
||||
* latency: replaying a full year of transcripts costs ~13s here vs. ~1.7s for
|
||||
* this window (heavy Web Shell users accumulate thousands of unpersisted
|
||||
* transcripts). Widen only alongside a cheaper scan.
|
||||
*/
|
||||
const LIVE_REBUILD_WINDOW_DAYS = 35;
|
||||
|
||||
export interface UsageSummaryRecord {
|
||||
version: 1;
|
||||
sessionId: string;
|
||||
|
|
@ -204,10 +222,38 @@ export function metricsToUsageRecord(
|
|||
};
|
||||
}
|
||||
|
||||
interface RebuildFromSessionJsonlOptions {
|
||||
/**
|
||||
* Session to exclude from the one-time persistence migration (the caller's
|
||||
* in-progress session — {@link persistSessionUsage} writes its authoritative
|
||||
* record on `/clear` or exit). It is still returned in the rebuilt records.
|
||||
*/
|
||||
skipSessionInRebuild?: string;
|
||||
/** Persist rebuilt records as a migration. Read-only callers pass `false`. */
|
||||
persist?: boolean;
|
||||
/**
|
||||
* Only replay transcripts whose file mtime is at/after this epoch-ms. Bounds
|
||||
* the scan when merging recent live sessions into an already-persisted
|
||||
* history (see {@link loadUsageHistoryWithLive}); undefined replays all.
|
||||
*/
|
||||
sinceMs?: number;
|
||||
/**
|
||||
* Session ids already covered by the persisted history. Their transcripts are
|
||||
* skipped by filename (`{sessionId}.jsonl`) with no file read, avoiding a full
|
||||
* replay of sessions the persisted file already records authoritatively.
|
||||
*/
|
||||
skipSessionIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
async function rebuildFromSessionJsonl(
|
||||
skipSessionInRebuild?: string,
|
||||
persist = true,
|
||||
options: RebuildFromSessionJsonlOptions = {},
|
||||
): Promise<UsageSummaryRecord[]> {
|
||||
const {
|
||||
skipSessionInRebuild,
|
||||
persist = true,
|
||||
sinceMs,
|
||||
skipSessionIds,
|
||||
} = options;
|
||||
const projectsDir = path.join(Storage.getGlobalQwenDir(), 'projects');
|
||||
try {
|
||||
if (!fs.existsSync(projectsDir)) return [];
|
||||
|
|
@ -243,6 +289,31 @@ async function rebuildFromSessionJsonl(
|
|||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
|
||||
// Bound the scan when merging live sessions into a persisted history:
|
||||
// skip transcripts untouched before `sinceMs`.
|
||||
if (sinceMs !== undefined) {
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`rebuildFromSessionJsonl: cannot stat ${filePath}: ${e}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (mtimeMs < sinceMs) continue;
|
||||
}
|
||||
|
||||
// Skip sessions the persisted history already records, before any file
|
||||
// read: the transcript filename is `{sessionId}.jsonl`
|
||||
// (chatRecordingService.ts), so the sessionId — the same value the
|
||||
// full-read path below derives from the first record — needs no I/O.
|
||||
if (skipSessionIds && skipSessionIds.size > 0) {
|
||||
const fileSessionId = path.basename(file, '.jsonl');
|
||||
if (skipSessionIds.has(fileSessionId)) continue;
|
||||
}
|
||||
|
||||
const records = await jsonl.read<ChatRecord>(filePath);
|
||||
if (records.length === 0) continue;
|
||||
|
||||
|
|
@ -337,13 +408,73 @@ export async function loadUsageHistory(
|
|||
}
|
||||
|
||||
return dedupBySessionId(
|
||||
await rebuildFromSessionJsonl(
|
||||
await rebuildFromSessionJsonl({
|
||||
skipSessionInRebuild,
|
||||
options?.persistRebuild ?? true,
|
||||
),
|
||||
persist: options?.persistRebuild ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the durable usage history **and** merge in sessions that were never
|
||||
* written to `usage_record.jsonl` — notably daemon / Web Shell sessions (only
|
||||
* the TUI `/clear` path persists usage) and any still-in-progress session.
|
||||
*
|
||||
* Unlike {@link loadUsageHistory}, which returns the persisted file verbatim
|
||||
* whenever it is non-empty (and so silently omits everything not yet
|
||||
* persisted), this replays recent transcripts for sessions the persisted file
|
||||
* does not already cover and unions the two. Persisted records win on any
|
||||
* sessionId conflict — they are the authoritative final snapshot. This is what
|
||||
* the daemon usage-dashboard reads so its totals reflect live Web Shell
|
||||
* activity. Read-only: never writes `usage_record.jsonl`.
|
||||
*
|
||||
* The transcript scan is bounded to a trailing window (mtime-based) so an
|
||||
* established history does not pay a full cross-project replay on every load.
|
||||
*/
|
||||
export async function loadUsageHistoryWithLive(options?: {
|
||||
/**
|
||||
* Only replay transcripts touched at/after this epoch-ms. Defaults to a
|
||||
* {@link LIVE_REBUILD_WINDOW_DAYS}-day trailing window (covers the dashboard's
|
||||
* summary + daily charts; see the constant for the heatmap trade-off).
|
||||
*/
|
||||
sinceMs?: number;
|
||||
}): Promise<UsageSummaryRecord[]> {
|
||||
let persisted: UsageSummaryRecord[] = [];
|
||||
try {
|
||||
const records = await jsonl.read<UsageSummaryRecord>(getUsageHistoryPath());
|
||||
persisted = records.filter((r) => r.version === 1);
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`loadUsageHistoryWithLive: failed to read usage file: ${e}`,
|
||||
);
|
||||
}
|
||||
|
||||
const persistedIds = new Set(persisted.map((r) => r.sessionId));
|
||||
|
||||
// The trailing window bounds an *incremental* live merge on top of persisted
|
||||
// history: old days come from the persisted file, so only recent transcripts
|
||||
// need replaying. When there is no persisted base (fresh machine, or a user
|
||||
// who only ever ran Web Shell so `/clear` never persisted), nothing else
|
||||
// covers older history — replay it all (unbounded) rather than silently
|
||||
// truncating the dashboard, matching the pre-existing empty-file behavior.
|
||||
const sinceMs =
|
||||
options?.sinceMs ??
|
||||
(persistedIds.size > 0
|
||||
? Date.now() - LIVE_REBUILD_WINDOW_DAYS * MS_PER_DAY
|
||||
: undefined);
|
||||
|
||||
const rebuilt = await rebuildFromSessionJsonl({
|
||||
persist: false,
|
||||
sinceMs,
|
||||
skipSessionIds: persistedIds,
|
||||
});
|
||||
|
||||
// Persisted records are the authoritative final snapshot, so they win on any
|
||||
// sessionId conflict — place them last (dedupBySessionId is last-wins). The
|
||||
// rebuilt set only adds sessions the persisted file never captured.
|
||||
return dedupBySessionId([...rebuilt, ...persisted]);
|
||||
}
|
||||
|
||||
export function getTimeRangeBounds(range: TimeRange): {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue