fix(gateway): resolve SQLite usage details (#118918)

This commit is contained in:
Vincent Koc
2026-08-04 06:09:21 +08:00
committed by GitHub
parent bc65789e13
commit d3ea24c669
2 changed files with 147 additions and 66 deletions
@@ -23,6 +23,7 @@ vi.mock("../session-utils.js", async () => {
const actual = await vi.importActual<typeof import("../session-utils.js")>("../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<void>,
) {
@@ -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",
+83 -46
View File
@@ -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<string, SessionsUsageCacheEntry>();
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({