diff --git a/src/gateway/server-methods/usage.sessions-usage.test.ts b/src/gateway/server-methods/usage.sessions-usage.test.ts index ff26e1eca420..de70ad1f6548 100644 --- a/src/gateway/server-methods/usage.sessions-usage.test.ts +++ b/src/gateway/server-methods/usage.sessions-usage.test.ts @@ -23,6 +23,7 @@ vi.mock("../session-utils.js", async () => { const actual = await vi.importActual("../session-utils.js"); return { ...actual, + loadSessionEntryReadOnly: vi.fn(actual.loadSessionEntryReadOnly), loadCombinedSessionStoreForGateway: vi.fn(() => ({ storePath: "(multiple)", store: {} })), }; }); @@ -33,6 +34,7 @@ vi.mock("../../infra/session-cost-usage.js", async () => { ); return { ...actual, + resolveExistingUsageSessionFile: vi.fn(actual.resolveExistingUsageSessionFile), discoverAllSessions: vi.fn(async (params?: { agentId?: string }) => { if (params?.agentId === "main") { return [ @@ -100,8 +102,9 @@ import { loadSessionCostSummariesFromCache, loadSessionLogs, loadSessionUsageTimeSeries, + resolveExistingUsageSessionFile, } from "../../infra/session-cost-usage.js"; -import { loadCombinedSessionStoreForGateway } from "../session-utils.js"; +import { loadCombinedSessionStoreForGateway, loadSessionEntryReadOnly } from "../session-utils.js"; import { testApi, usageHandlers } from "./usage.js"; const TEST_RUNTIME_CONFIG = { @@ -182,6 +185,20 @@ function expectSuccessfulSessionsUsage( return result.sessions; } +function mockStoredSession(key: string, sessionId: string) { + const entry = { sessionId, updatedAt: 1_000 }; + vi.mocked(loadSessionEntryReadOnly).mockReturnValueOnce({ + cfg: TEST_RUNTIME_CONFIG, + canonicalKey: key, + entry, + legacyKey: undefined, + store: { [key]: entry }, + storeKeys: [key], + storePath: "/tmp/agents/opus/sessions/sessions.json", + }); + return entry; +} + async function withUsageState( run: (writeSessionFile: (fileName: string) => string) => Promise, ) { @@ -504,7 +521,8 @@ describe("sessions.usage", () => { it("uses the requested agent for legacy specific session keys", async () => { await withUsageState(async (writeSessionFile) => { - const sessionFile = writeSessionFile("main.jsonl"); + writeSessionFile("main.jsonl"); + mockStoredSession("agent:opus:main", "main"); vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({ storePath: "(multiple)", @@ -532,7 +550,7 @@ describe("sessions.usage", () => { agentId: "opus", sessions: expect.arrayContaining([ expect.objectContaining({ - sessionFile: fs.realpathSync(sessionFile), + sessionFile: expect.stringMatching(/^sqlite:/), sessionId: "main", }), ]), @@ -550,7 +568,8 @@ describe("sessions.usage", () => { }; await withUsageState(async (writeSessionFile) => { - const sessionFile = writeSessionFile("current.jsonl"); + writeSessionFile("current.jsonl"); + mockStoredSession("global", "current"); const sessionEntry = { sessionId: "current", @@ -583,7 +602,7 @@ describe("sessions.usage", () => { agentId: "opus", sessions: expect.arrayContaining([ expect.objectContaining({ - sessionFile: fs.realpathSync(sessionFile), + sessionFile: expect.stringMatching(/^sqlite:/), sessionId: "current", }), ]), @@ -637,6 +656,7 @@ describe("sessions.usage", () => { await withUsageState(async (writeSessionFile) => { writeSessionFile("s-opus.jsonl"); + mockStoredSession(storeKey, "s-opus"); // Swap the store mock for this test: the canonical key differs from the discovered key // but points at the same sessionId. @@ -672,6 +692,7 @@ describe("sessions.usage", () => { await withUsageState(async (writeSessionFile) => { writeSessionFile("current.jsonl"); writeSessionFile("old.jsonl.reset.2026-02-01T00-00-00.000Z"); + mockStoredSession(storeKey, "current"); vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({ storePath: "(multiple)", @@ -750,6 +771,7 @@ describe("sessions.usage", () => { await withUsageState(async (writeSessionFile) => { writeSessionFile("run-dup.jsonl"); + mockStoredSession(preferredKey, "run-dup"); vi.mocked(loadCombinedSessionStoreForGateway).mockReturnValue({ storePath: "(multiple)", @@ -774,6 +796,13 @@ describe("sessions.usage", () => { const sessions = expectSuccessfulSessionsUsage(respond); expect(sessions).toHaveLength(1); expect(sessions[0]?.key).toBe(preferredKey); + expect(vi.mocked(loadSessionCostSummariesFromCache)).toHaveBeenCalledWith( + expect.objectContaining({ + sessions: expect.arrayContaining([ + expect.objectContaining({ sessionFile: expect.stringMatching(/^sqlite:/) }), + ]), + }), + ); }); }); @@ -789,28 +818,43 @@ describe("sessions.usage", () => { expect(error?.message).toContain("Invalid session reference"); }); - it("passes parsed agentId into sessions.usage.timeseries", async () => { - await runSessionsUsageTimeseries({ - key: "agent:opus:s-opus", - }); + it("passes a canonical SQLite target into sessions.usage.timeseries", async () => { + mockStoredSession("agent:opus:s-opus", "s-opus"); + await runSessionsUsageTimeseries({ key: "agent:opus:s-opus" }); - expect(vi.mocked(loadSessionUsageTimeSeries)).toHaveBeenCalled(); - expect( - (mockArg(vi.mocked(loadSessionUsageTimeSeries), 0, 0) as { agentId?: string }).agentId, - ).toBe("opus"); + expect(vi.mocked(loadSessionUsageTimeSeries)).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "opus", sessionFile: expect.stringMatching(/^sqlite:/) }), + ); }); - it("passes parsed agentId into sessions.usage.logs", async () => { - await runSessionsUsageLogs({ - key: "agent:opus:s-opus", - }); + it("passes a canonical SQLite target into sessions.usage.logs", async () => { + mockStoredSession("agent:opus:s-opus", "s-opus"); + await runSessionsUsageLogs({ key: "agent:opus:s-opus" }); - expect(vi.mocked(loadSessionLogs)).toHaveBeenCalled(); - expect((mockArg(vi.mocked(loadSessionLogs), 0, 0) as { agentId?: string }).agentId).toBe( - "opus", + expect(vi.mocked(loadSessionLogs)).toHaveBeenCalledWith( + expect.objectContaining({ agentId: "opus", sessionFile: expect.stringMatching(/^sqlite:/) }), ); }); + it("preserves JSONL detail lookup for storeless sessions", async () => { + await withUsageState(async (writeSessionFile) => { + const sessionFile = writeSessionFile("storeless.jsonl"); + await runSessionsUsageTimeseries({ key: "agent:opus:storeless" }); + expect(vi.mocked(loadSessionUsageTimeSeries)).toHaveBeenCalledWith( + expect.objectContaining({ sessionFile, sessionEntry: undefined }), + ); + }); + }); + + it("fails closed when a canonical stored target no longer matches", async () => { + const key = "agent:opus:stale"; + mockStoredSession(key, "stale"); + vi.mocked(resolveExistingUsageSessionFile).mockReturnValueOnce(undefined); + const respond = await runSessionsUsageTimeseries({ key }); + expect(mockArg(respond, 0, 0)).toBe(false); + expect(vi.mocked(loadSessionUsageTimeSeries)).not.toHaveBeenCalled(); + }); + it("rejects traversal-style keys in timeseries/log lookups", async () => { const timeseriesRespond = await runSessionsUsageTimeseries({ key: "agent:opus:../../etc/passwd", diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 6399abf74372..d000f19535e2 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -9,6 +9,7 @@ import { validateSessionsUsageParams, } from "../../../packages/gateway-protocol/src/index.js"; import { listAgentIds, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { parseSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -128,6 +129,48 @@ const sessionsUsageCache = new Map(); class SessionsUsageInvalidRequestError extends Error {} +type ResolvedSessionUsageTarget = { + entry: SessionEntry | undefined; + agentId: string; + sessionId: string; + sessionFile: string; +}; + +function resolveSessionUsageTarget( + key: string, + config: OpenClawConfig, + agentIdHint?: string, +): ResolvedSessionUsageTarget | undefined { + const { canonicalKey, entry, storePath } = loadSessionEntryReadOnly( + key, + agentIdHint ? { agentId: agentIdHint } : undefined, + ); + const parsed = parseAgentSessionKey(key); + const agentId = parsed?.agentId ?? agentIdHint ?? resolveDefaultAgentId(config); + const sessionId = entry?.sessionId ?? parsed?.rest ?? key; + const sessionFile = entry + ? resolveExistingUsageSessionFile({ + agentId, + sessionId, + sessionTarget: { + agentId, + sessionId, + sessionKey: canonicalKey, + storePath, + }, + }) + : resolveExistingUsageSessionFile({ + agentId, + sessionId, + sessionFile: resolveSessionFilePath( + sessionId, + undefined, + resolveSessionFilePathOptions({ storePath, agentId }), + ), + }); + return sessionFile ? { entry, agentId, sessionId, sessionFile } : undefined; +} + function findCostUsageCacheEvictionKey(): string | undefined { for (const [key, entry] of costUsageCache) { // Prefer evicting settled entries so duplicate callers can still join active loads. @@ -277,25 +320,14 @@ function resolveSessionUsageFileOrRespond( key: string, respond: RespondFn, config: OpenClawConfig, -): { - config: OpenClawConfig; - entry: SessionEntry | undefined; - agentId: string; - sessionId: string; - sessionFile: string; -} | null { - const { entry, storePath } = loadSessionEntryReadOnly(key); - - // For discovered sessions (not in store), try using key as sessionId directly - const parsed = parseAgentSessionKey(key); - const agentId = parsed?.agentId ?? resolveDefaultAgentId(config); - const rawSessionId = parsed?.rest ?? key; - const sessionId = entry?.sessionId ?? rawSessionId; - let sessionFile: string; +): (ResolvedSessionUsageTarget & { config: OpenClawConfig }) | null { + let resolved: ResolvedSessionUsageTarget | undefined; try { - const pathOpts = resolveSessionFilePathOptions({ storePath, agentId }); - sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts); + resolved = resolveSessionUsageTarget(key, config); } catch { + resolved = undefined; + } + if (!resolved) { respond( false, undefined, @@ -303,8 +335,7 @@ function resolveSessionUsageFileOrRespond( ); return null; } - - return { config, entry, agentId, sessionId, sessionFile }; + return { config, ...resolved }; } const parseDateParts = (raw: unknown): DateParts | undefined => { @@ -1346,7 +1377,7 @@ export const usageHandlers: GatewayRequestHandlers = { load: async () => { // Load session store for named sessions only on a result-cache miss. const sessionStoreOpts = effectiveAgentId ? { agentId: effectiveAgentId } : {}; - const { storePath, store } = loadCombinedSessionStoreForGateway(config, sessionStoreOpts); + const { store } = loadCombinedSessionStoreForGateway(config, sessionStoreOpts); const scopedStore = effectiveAgentId ? filterSessionStoreByAgent({ config, @@ -1387,47 +1418,53 @@ export const usageHandlers: GatewayRequestHandlers = { const storeEntry = storeMatch?.entry ?? storeByIdMatch?.entry; const sessionId = storeEntry?.sessionId ?? keyRest; - // Resolve the session file path - let sessionFile: string | undefined; + // Stored sessions are canonical SQLite targets. JSONL discovery remains only for + // sessions without a store row, so retired locators cannot redirect live state. + let resolved: ResolvedSessionUsageTarget | undefined; try { - const pathOpts = resolveSessionFilePathOptions({ - storePath: storePath !== "(multiple)" ? storePath : undefined, - agentId: agentIdFromKey, - }); - sessionFile = resolveExistingUsageSessionFile({ - sessionId, - sessionEntry: storeEntry, - sessionFile: resolveSessionFilePath(sessionId, storeEntry, pathOpts), - agentId: agentIdFromKey, - }); + resolved = resolveSessionUsageTarget(resolvedStoreKey, config, agentIdFromKey); + if ( + !resolved || + resolved.agentId !== agentIdFromKey || + resolved.sessionId !== sessionId + ) { + throw new Error("session target mismatch"); + } } catch { throw new SessionsUsageInvalidRequestError( `Invalid session reference: ${specificKey}`, ); } + const { sessionFile } = resolved; - if (sessionFile) { + let updatedAt: number | undefined; + if (parseSqliteSessionFileMarker(sessionFile)) { + updatedAt = storeEntry?.updatedAt ?? now; + } else { try { const stats = fs.statSync(sessionFile); if (stats.isFile()) { - maybeMergeFamilyEntry({ - mergedEntries, - groupingMode, - base: { - key: resolvedStoreKey, - agentId: agentIdFromKey, - sessionId, - sessionFile, - label: storeEntry?.label, - updatedAt: storeEntry?.updatedAt ?? stats.mtimeMs, - storeEntry, - }, - }); + updatedAt = storeEntry?.updatedAt ?? stats.mtimeMs; } } catch { // File doesn't exist - no results for this key } } + if (updatedAt !== undefined) { + maybeMergeFamilyEntry({ + mergedEntries, + groupingMode, + base: { + key: resolvedStoreKey, + agentId: agentIdFromKey, + sessionId, + sessionFile, + label: storeEntry?.label, + updatedAt, + storeEntry, + }, + }); + } } else { // Full discovery for list view const discoveredSessions = await discoverAllSessionsForUsage({