fix: preserve archived transcript identity in usage resolution
- 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.
This commit is contained in:
parent
3b88d615b0
commit
038186e30d
@ -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:<id>:<sessionId>`) 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:<id>:<sessionId>`) 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[] = [];
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user