Merge 59087d5198e887282e5eafdb27e06a398752fd85 into d78e13f545136fcbba1feceecc5e0485a06c33a6
This commit is contained in:
commit
8c394eb7ef
@ -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[] = [];
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user