diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 8b6be35f654..865ab231d7e 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,45 @@ 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 — use path.resolve as fallback base + // to still enforce containment against traversal attacks + realSessionsDir = path.resolve(sessionsDir); + } + const realSessionFile = path.resolve(sessionFile); + if ( + !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 +132,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 +498,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 +606,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 +626,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 ba9e10b1f4a..15a3c0c2e1d 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -231,6 +231,103 @@ describe("session cost usage", () => { }); }); + it("includes reset and deleted transcript files in aggregate usage summaries", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-archived-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const day = new Date("2026-03-13T10:00:00.000Z"); + + const files = [ + "sess-active.jsonl", + "sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", + "sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", + "sess-backup.jsonl.bak.2026-03-13T10-20-00.000Z", + "sessions.json.bak.1737420882", + ]; + + for (const [i, name] of files.entries()) { + const filePath = path.join(sessionsDir, name); + await fs.writeFile( + filePath, + JSON.stringify({ + type: "message", + timestamp: day.toISOString(), + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 10 + i, + output: 20 + i, + totalTokens: 30 + i * 2, + cost: { total: 0.01 * (i + 1) }, + }, + }, + }), + "utf-8", + ); + } + + await withStateDir(root, async () => { + const summary = await loadCostUsageSummary({ + startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), + endMs: new Date("2026-03-13T23:59:59.999Z").getTime(), + }); + expect(summary.daily).toHaveLength(1); + expect(summary.totals.totalTokens).toBe(96); + expect(summary.totals.totalCost).toBeCloseTo(0.06, 8); + }); + }); + + it("discovers archived reset/deleted transcripts and preserves base session ids", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discover-archived-")); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + const files = [ + ["sess-active.jsonl", "active hello"], + ["sess-reset.jsonl.reset.2026-03-13T10-05-00.000Z", "reset hello"], + ["sess-deleted.jsonl.deleted.2026-03-13T10-10-00.000Z", "deleted hello"], + ["foo.jsonl.bar.jsonl.reset.2026-03-13T10-15-00.000Z", "compound hello"], + ] as const; + + for (const [name, text] of files) { + const filePath = path.join(sessionsDir, name); + await fs.writeFile( + filePath, + JSON.stringify({ + type: "message", + timestamp: "2026-03-13T10:00:00.000Z", + message: { + role: "user", + content: text, + }, + }), + "utf-8", + ); + } + + await withStateDir(root, async () => { + const sessions = await discoverAllSessions({ + startMs: new Date("2026-03-13T00:00:00.000Z").getTime(), + }); + const ids = sessions.map((session) => session.sessionId).toSorted(); + expect(ids).toEqual(["foo.jsonl.bar", "sess-active", "sess-deleted", "sess-reset"]); + 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(); + }); + }); + it("resolves non-main absolute sessionFile using explicit agentId for cost summary", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cost-agent-")); const workerSessionsDir = path.join(root, "agents", "worker1", "sessions"); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index 230ebd60c2e..71025699aeb 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -5,6 +5,10 @@ import type { NormalizedUsage, UsageLike } from "../agents/usage.js"; import { normalizeUsage } from "../agents/usage.js"; import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; import type { OpenClawConfig } from "../config/config.js"; +import { + isPrimarySessionTranscriptFileName, + parseSessionArchiveTimestamp, +} from "../config/sessions/artifacts.js"; import { resolveSessionFilePath, resolveSessionTranscriptsDirForAgent, @@ -53,6 +57,32 @@ export type { SessionUsageTimeSeries, } from "./session-cost-usage.types.js"; +const isArchivedUsageTranscriptFilename = (name: string): boolean => + parseSessionArchiveTimestamp(name, "reset") !== null || + parseSessionArchiveTimestamp(name, "deleted") !== null; + +const isSessionTranscriptFilename = (name: string): boolean => + isPrimarySessionTranscriptFileName(name) || isArchivedUsageTranscriptFilename(name); + +const deriveSessionIdFromFilename = (name: string): string => { + for (const reason of ["reset", "deleted"] as const) { + const archivedAt = parseSessionArchiveTimestamp(name, reason); + if (archivedAt === null) { + continue; + } + const marker = `.${reason}.`; + const index = name.lastIndexOf(marker); + if (index >= 0) { + const base = name.slice(0, index); + if (base.endsWith(".jsonl")) { + return base.slice(0, -6); + } + return base; + } + } + return name.endsWith(".jsonl") ? name.slice(0, -6) : name; +}; + const emptyTotals = (): CostUsageTotals => ({ input: 0, output: 0, @@ -318,7 +348,7 @@ export async function loadCostUsageSummary(params?: { const files = ( await Promise.all( entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .filter((entry) => entry.isFile() && isSessionTranscriptFilename(entry.name)) .map(async (entry) => { const filePath = path.join(sessionsDir, entry.name); const stats = await fs.promises.stat(filePath).catch(() => null); @@ -393,7 +423,7 @@ export async function discoverAllSessions(params?: { const discovered: DiscoveredSession[] = []; for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { + if (!entry.isFile() || !isSessionTranscriptFilename(entry.name)) { continue; } @@ -409,8 +439,9 @@ export async function discoverAllSessions(params?: { } // Do not exclude by endMs: a session can have activity in range even if it continued later. - // Extract session ID from filename (remove .jsonl) - const sessionId = entry.name.slice(0, -6); + // 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; @@ -452,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;