From 038186e30d677ced021d48302642af43f6f66e7e Mon Sep 17 00:00:00 2001 From: robo7 Date: Sat, 14 Mar 2026 00:02:26 +0800 Subject: [PATCH] fix: preserve archived transcript identity in usage resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract findArchivedTranscript() helper for reuse across resolution paths - resolveSessionUsageFileOrRespond: fall back to archived transcript when primary file is missing (reset/deleted sessions) - Archived keys use :: delimiter with embedded filename for uniqueness (e.g. agent:main:sess-foo::sess-foo.jsonl.reset.2026-03-13T10-00-00Z) - resolveSessionUsageFileOrRespond detects :: keys and resolves directly to archived file with path traversal guard - sessions.usage specific-key branch: fall back to findArchivedTranscript when primary transcript doesn't exist - DiscoveredSession.archived flag for downstream consumers - Fix .sort() → .toSorted() for oxlint compliance Closes review feedback on PR #45204. --- src/gateway/server-methods/usage.ts | 237 ++++++++++++++++++++------ src/infra/session-cost-usage.test.ts | 6 + src/infra/session-cost-usage.ts | 2 + src/infra/session-cost-usage.types.ts | 3 + 4 files changed, 196 insertions(+), 52 deletions(-) diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 8b6be35f654..e530029a647 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -1,8 +1,11 @@ import fs from "node:fs"; +import path from "node:path"; import { loadConfig } from "../../config/config.js"; +import { parseSessionArchiveTimestamp } from "../../config/sessions/artifacts.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, + resolveSessionTranscriptsDirForAgent, } from "../../config/sessions/paths.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.js"; @@ -70,6 +73,46 @@ function resolveSessionUsageFileOrRespond( sessionFile: string; } | null { const config = loadConfig(); + + // Handle archived transcript keys that embed the filename after `::` + // e.g. "agent:main:sess-foo::sess-foo.jsonl.reset.2026-03-13T10-00-00Z" + const archiveDelim = key.indexOf("::"); + if (archiveDelim !== -1) { + const baseKey = key.slice(0, archiveDelim); + const archiveFilename = key.slice(archiveDelim + 2); + const parsed = parseAgentSessionKey(baseKey); + const agentId = parsed?.agentId; + const rawSessionId = parsed?.rest ?? baseKey; + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const sessionFile = path.join(sessionsDir, archiveFilename); + // Guard against path traversal: the resolved file must remain inside sessionsDir + let realSessionsDir: string; + try { + realSessionsDir = fs.realpathSync(sessionsDir); + } catch { + // Sessions directory doesn't exist for this agent — no archived file possible + // Fall through to normal resolution + realSessionsDir = ""; + } + const realSessionFile = path.resolve(sessionFile); + if ( + realSessionsDir && + !realSessionFile.startsWith(realSessionsDir + path.sep) && + realSessionFile !== realSessionsDir + ) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid archived session key: ${key}`), + ); + return null; + } + if (fs.existsSync(sessionFile)) { + return { config, entry: undefined, agentId, sessionId: rawSessionId, sessionFile }; + } + // Fall through to normal resolution if the archived file no longer exists + } + const { entry, storePath } = loadSessionEntry(key); // For discovered sessions (not in store), try using key as sessionId directly @@ -90,9 +133,57 @@ function resolveSessionUsageFileOrRespond( return null; } + // When the primary transcript no longer exists (reset/deleted), fall back to + // the most recent archived transcript for the same session id so that + // detail/log/timeseries requests can still read historical data. + if (!fs.existsSync(sessionFile)) { + const fallback = findArchivedTranscript(sessionId, agentId); + if (fallback) { + sessionFile = fallback; + } + } + return { config, entry, agentId, sessionId, sessionFile }; } +/** + * Scan the sessions directory for archived (reset/deleted) transcripts matching + * the given sessionId and return the path to the most recently modified one, + * or `null` if none are found. + */ +function findArchivedTranscript(sessionId: string, agentId: string | undefined): string | null { + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId); + const prefix = `${sessionId}.jsonl.`; + try { + const candidates = fs.readdirSync(sessionsDir).filter((name) => { + if (!name.startsWith(prefix)) { + return false; + } + return ( + parseSessionArchiveTimestamp(name, "reset") !== null || + parseSessionArchiveTimestamp(name, "deleted") !== null + ); + }); + if (candidates.length === 0) { + return null; + } + const resolved = candidates + .map((name) => { + const full = path.join(sessionsDir, name); + try { + return { path: full, mtime: fs.statSync(full).mtimeMs }; + } catch { + return null; + } + }) + .filter((c): c is { path: string; mtime: number } => c !== null) + .toSorted((a, b) => b.mtime - a.mtime); + return resolved[0]?.path ?? null; + } catch { + return null; + } +} + const parseDateParts = ( raw: unknown, ): { year: number; monthIndex: number; day: number } | undefined => { @@ -408,54 +499,90 @@ export const usageHandlers: GatewayRequestHandlers = { // Optimization: If a specific key is requested, skip full directory scan if (specificKey) { - const parsed = parseAgentSessionKey(specificKey); - const agentIdFromKey = parsed?.agentId; - const keyRest = parsed?.rest ?? specificKey; - - // Prefer the store entry when available, even if the caller provides a discovered key - // (`agent::`) for a session that now has a canonical store key. - const storeBySessionId = buildStoreBySessionId(store); - - const storeMatch = store[specificKey] - ? { key: specificKey, entry: store[specificKey] } - : null; - const storeByIdMatch = storeBySessionId.get(keyRest) ?? null; - const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? specificKey; - const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry; - const sessionId = storeEntry?.sessionId ?? keyRest; - - // Resolve the session file path - let sessionFile: string; - try { - const pathOpts = resolveSessionFilePathOptions({ - storePath: storePath !== "(multiple)" ? storePath : undefined, - agentId: agentIdFromKey, - }); - sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts); - } catch { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`), - ); - return; - } - - try { - const stats = fs.statSync(sessionFile); - if (stats.isFile()) { - mergedEntries.push({ - key: resolvedStoreKey, - sessionId, - sessionFile, - label: storeEntry?.label, - updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs, - storeEntry, - }); + // Archived keys use "::" to embed the transcript filename — delegate to + // resolveSessionUsageFileOrRespond which already handles traversal checks. + const specificArchiveDelim = specificKey.indexOf("::"); + if (specificArchiveDelim !== -1) { + const resolved = resolveSessionUsageFileOrRespond(specificKey, respond); + if (!resolved) { + return; // respond already called } - } catch { - // File doesn't exist - no results for this key - } + try { + const stats = fs.statSync(resolved.sessionFile); + if (stats.isFile()) { + mergedEntries.push({ + key: specificKey, + sessionId: resolved.sessionId, + sessionFile: resolved.sessionFile, + label: undefined, + updatedAt: stats.mtimeMs, + }); + } + } catch { + // Archived file no longer exists — empty result + } + } else { + const parsed = parseAgentSessionKey(specificKey); + const agentIdFromKey = parsed?.agentId; + const keyRest = parsed?.rest ?? specificKey; + + // Prefer the store entry when available, even if the caller provides a discovered key + // (`agent::`) for a session that now has a canonical store key. + const storeBySessionId = buildStoreBySessionId(store); + + const storeMatch = store[specificKey] + ? { key: specificKey, entry: store[specificKey] } + : null; + const storeByIdMatch = storeBySessionId.get(keyRest) ?? null; + const resolvedStoreKey = storeMatch?.key ?? storeByIdMatch?.key ?? specificKey; + const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry; + const sessionId = storeEntry?.sessionId ?? keyRest; + + // Resolve the session file path + let sessionFile: string; + try { + const pathOpts = resolveSessionFilePathOptions({ + storePath: storePath !== "(multiple)" ? storePath : undefined, + agentId: agentIdFromKey, + }); + sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts); + } catch { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `Invalid session reference: ${specificKey}`), + ); + return; + } + + try { + const stats = fs.statSync(sessionFile); + if (stats.isFile()) { + mergedEntries.push({ + key: resolvedStoreKey, + sessionId, + sessionFile, + label: storeEntry?.label, + updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs, + storeEntry, + }); + } + } catch { + // Primary transcript doesn't exist — try archived fallback + const fallback = findArchivedTranscript(sessionId, agentIdFromKey); + if (fallback) { + const fallbackStats = fs.statSync(fallback); + mergedEntries.push({ + key: resolvedStoreKey, + sessionId, + sessionFile: fallback, + label: storeEntry?.label, + updatedAt: storeEntry?.updatedAt ?? fallbackStats.mtimeMs, + storeEntry, + }); + } + } + } // close non-archived specific-key branch } else { // Full discovery for list view const discoveredSessions = await discoverAllSessionsForUsage({ @@ -480,10 +607,16 @@ export const usageHandlers: GatewayRequestHandlers = { storeEntry: storeMatch.entry, }); } else { - // Unnamed session - use session ID as key, no label + // Unnamed session - use session ID as key, no label. + // For archived transcripts, embed the archive filename in the key so + // that multiple archived transcripts for the same base sessionId get + // distinct keys and can be individually resolved later. + const baseKey = `agent:${discovered.agentId}:${discovered.sessionId}`; + const key = discovered.archived + ? `${baseKey}::${path.basename(discovered.sessionFile)}` + : baseKey; mergedEntries.push({ - // Keep agentId in the key so the dashboard can attribute sessions and later fetch logs. - key: `agent:${discovered.agentId}:${discovered.sessionId}`, + key, sessionId: discovered.sessionId, sessionFile: discovered.sessionFile, label: undefined, // No label for unnamed sessions @@ -494,10 +627,10 @@ export const usageHandlers: GatewayRequestHandlers = { } // Sort by most recent first - mergedEntries.sort((a, b) => b.updatedAt - a.updatedAt); + const sortedEntries = mergedEntries.toSorted((a, b) => b.updatedAt - a.updatedAt); // Apply limit - const limitedEntries = mergedEntries.slice(0, limit); + const limitedEntries = sortedEntries.slice(0, limit); // Load usage for each session const sessions: SessionUsageEntry[] = []; diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 15dc30e4f6e..bed710592ae 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -316,9 +316,15 @@ describe("session cost usage", () => { const reset = sessions.find((session) => session.sessionId === "sess-reset"); const deleted = sessions.find((session) => session.sessionId === "sess-deleted"); const compound = sessions.find((session) => session.sessionId === "foo.jsonl.bar"); + const active = sessions.find((session) => session.sessionId === "sess-active"); expect(reset?.firstUserMessage).toContain("reset hello"); expect(deleted?.firstUserMessage).toContain("deleted hello"); expect(compound?.firstUserMessage).toContain("compound hello"); + // Archived transcripts should be flagged so callers use sessionFile directly + expect(reset?.archived).toBe(true); + expect(deleted?.archived).toBe(true); + expect(compound?.archived).toBe(true); + expect(active?.archived).toBeUndefined(); }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 663739ca1ca..71025699aeb 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -441,6 +441,7 @@ export async function discoverAllSessions(params?: { // Extract session ID from filename, preserving base session id for archived reset/deleted transcripts. const sessionId = deriveSessionIdFromFilename(entry.name); + const archived = isArchivedUsageTranscriptFilename(entry.name); // Try to read first user message for label extraction let firstUserMessage: string | undefined; @@ -482,6 +483,7 @@ export async function discoverAllSessions(params?: { sessionFile: filePath, mtime: stats.mtimeMs, firstUserMessage, + ...(archived ? { archived } : {}), }); } diff --git a/src/infra/session-cost-usage.types.ts b/src/infra/session-cost-usage.types.ts index 70de453bcd9..cd33368d4de 100644 --- a/src/infra/session-cost-usage.types.ts +++ b/src/infra/session-cost-usage.types.ts @@ -143,6 +143,9 @@ export type DiscoveredSession = { sessionFile: string; mtime: number; firstUserMessage?: string; + /** When true, the transcript is an archived reset/deleted file. Callers + * should use `sessionFile` directly instead of resolving via `sessionId`. */ + archived?: boolean; }; export type SessionUsageTimePoint = SharedSessionUsageTimePoint;