mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(gateway): resolve SQLite usage details (#118918)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user