Merge 59087d5198e887282e5eafdb27e06a398752fd85 into d78e13f545136fcbba1feceecc5e0485a06c33a6

This commit is contained in:
robo7 2026-03-21 12:48:04 +08:00 committed by GitHub
commit 8c394eb7ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 320 additions and 56 deletions

View File

@ -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:<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 +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[] = [];

View File

@ -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");

View File

@ -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 } : {}),
});
}

View File

@ -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;