mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 11:37:26 +00:00
sessions: dedupe shared stores and harden archive matching
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
||||
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
@@ -15,6 +15,10 @@ vi.mock("../agents/agent-scope.js", () => ({
|
||||
}));
|
||||
|
||||
describe("resolveSessionStoreTargets", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves the default agent store when no selector is provided", () => {
|
||||
resolveDefaultAgentIdMock.mockReturnValue("main");
|
||||
resolveStorePathMock.mockReturnValue("/tmp/main-sessions.json");
|
||||
@@ -44,6 +48,21 @@ describe("resolveSessionStoreTargets", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("dedupes shared store paths for --all-agents", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
|
||||
|
||||
const targets = resolveSessionStoreTargets(
|
||||
{
|
||||
session: { store: "/tmp/shared-sessions.json" },
|
||||
},
|
||||
{ allAgents: true },
|
||||
);
|
||||
|
||||
expect(targets).toEqual([{ agentId: "main", storePath: "/tmp/shared-sessions.json" }]);
|
||||
expect(resolveStorePathMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects unknown agent ids", () => {
|
||||
listAgentIdsMock.mockReturnValue(["main", "work"]);
|
||||
expect(() => resolveSessionStoreTargets({}, { agent: "ghost" })).toThrow(/Unknown agent id/);
|
||||
|
||||
@@ -14,6 +14,16 @@ export type SessionStoreTarget = {
|
||||
storePath: string;
|
||||
};
|
||||
|
||||
function dedupeTargetsByStorePath(targets: SessionStoreTarget[]): SessionStoreTarget[] {
|
||||
const deduped = new Map<string, SessionStoreTarget>();
|
||||
for (const target of targets) {
|
||||
if (!deduped.has(target.storePath)) {
|
||||
deduped.set(target.storePath, target);
|
||||
}
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
export function resolveSessionStoreTargets(
|
||||
cfg: OpenClawConfig,
|
||||
opts: SessionStoreSelectionOptions,
|
||||
@@ -38,10 +48,11 @@ export function resolveSessionStoreTargets(
|
||||
}
|
||||
|
||||
if (allAgents) {
|
||||
return listAgentIds(cfg).map((agentId) => ({
|
||||
const targets = listAgentIds(cfg).map((agentId) => ({
|
||||
agentId,
|
||||
storePath: resolveStorePath(cfg.session?.store, { agentId }),
|
||||
}));
|
||||
return dedupeTargetsByStorePath(targets);
|
||||
}
|
||||
|
||||
if (hasAgent) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() =>
|
||||
@@ -59,6 +59,16 @@ function createRuntime(): { runtime: RuntimeEnv; logs: string[] } {
|
||||
}
|
||||
|
||||
describe("sessionsCommand default store agent selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveStorePathMock.mockImplementation(
|
||||
(_store: string | undefined, opts?: { agentId?: string }) => {
|
||||
return `/tmp/sessions-${opts?.agentId ?? "missing"}.json`;
|
||||
},
|
||||
);
|
||||
loadSessionStoreMock.mockImplementation(() => ({}));
|
||||
});
|
||||
|
||||
it("includes agentId on sessions rows for --all-agents JSON output", async () => {
|
||||
resolveStorePathMock.mockClear();
|
||||
loadSessionStoreMock.mockReset();
|
||||
@@ -82,6 +92,34 @@ describe("sessionsCommand default store agent selection", () => {
|
||||
expect(payload.sessions?.map((session) => session.agentId)).toContain("voice");
|
||||
});
|
||||
|
||||
it("avoids duplicate rows when --all-agents resolves to a shared store path", async () => {
|
||||
resolveStorePathMock.mockReset();
|
||||
resolveStorePathMock.mockReturnValue("/tmp/shared-sessions.json");
|
||||
loadSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"agent:main:room": { sessionId: "s1", updatedAt: Date.now() - 60_000, model: "pi:opus" },
|
||||
"agent:voice:room": { sessionId: "s2", updatedAt: Date.now() - 30_000, model: "pi:opus" },
|
||||
});
|
||||
const { runtime, logs } = createRuntime();
|
||||
|
||||
await sessionsCommand({ allAgents: true, json: true }, runtime);
|
||||
|
||||
const payload = JSON.parse(logs[0] ?? "{}") as {
|
||||
count?: number;
|
||||
stores?: Array<{ agentId: string; path: string }>;
|
||||
allAgents?: boolean;
|
||||
sessions?: Array<{ key: string; agentId?: string }>;
|
||||
};
|
||||
expect(payload.count).toBe(2);
|
||||
expect(payload.allAgents).toBe(true);
|
||||
expect(payload.stores).toEqual([{ agentId: "main", path: "/tmp/shared-sessions.json" }]);
|
||||
expect(payload.sessions?.map((session) => session.agentId).toSorted()).toEqual([
|
||||
"main",
|
||||
"voice",
|
||||
]);
|
||||
expect(loadSessionStoreMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses configured default agent id when resolving implicit session store path", async () => {
|
||||
resolveStorePathMock.mockClear();
|
||||
const { runtime, logs } = createRuntime();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveFreshSessionTotalTokens } from "../config/sessions.js";
|
||||
import { classifySessionKey } from "../gateway/session-utils.js";
|
||||
import { info } from "../globals.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { isRich, theme } from "../terminal/theme.js";
|
||||
import { resolveSessionStoreTargets } from "./session-store-targets.js";
|
||||
@@ -87,6 +88,7 @@ export async function sessionsCommand(
|
||||
opts: { json?: boolean; store?: string; active?: string; agent?: string; allAgents?: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
const aggregateAgents = opts.allAgents === true;
|
||||
const cfg = loadConfig();
|
||||
const displayDefaults = resolveSessionDisplayDefaults(cfg);
|
||||
const configContextTokens =
|
||||
@@ -122,7 +124,7 @@ export async function sessionsCommand(
|
||||
const store = loadSessionStore(target.storePath);
|
||||
return toSessionDisplayRows(store).map((row) => ({
|
||||
...row,
|
||||
agentId: target.agentId,
|
||||
agentId: parseAgentSessionKey(row.key)?.agentId ?? target.agentId,
|
||||
kind: classifySessionKey(row.key, store[row.key]),
|
||||
}));
|
||||
})
|
||||
@@ -139,17 +141,18 @@ export async function sessionsCommand(
|
||||
|
||||
if (opts.json) {
|
||||
const multi = targets.length > 1;
|
||||
const aggregate = aggregateAgents || multi;
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
path: multi ? null : (targets[0]?.storePath ?? null),
|
||||
stores: multi
|
||||
path: aggregate ? null : (targets[0]?.storePath ?? null),
|
||||
stores: aggregate
|
||||
? targets.map((target) => ({
|
||||
agentId: target.agentId,
|
||||
path: target.storePath,
|
||||
}))
|
||||
: undefined,
|
||||
allAgents: multi ? true : undefined,
|
||||
allAgents: aggregateAgents ? true : undefined,
|
||||
count: rows.length,
|
||||
activeMinutes: activeMinutes ?? null,
|
||||
sessions: rows.map((r) => {
|
||||
@@ -172,7 +175,7 @@ export async function sessionsCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 1) {
|
||||
if (targets.length === 1 && !aggregateAgents) {
|
||||
runtime.log(info(`Session store: ${targets[0]?.storePath}`));
|
||||
} else {
|
||||
runtime.log(
|
||||
@@ -189,7 +192,7 @@ export async function sessionsCommand(
|
||||
}
|
||||
|
||||
const rich = isRich();
|
||||
const showAgentColumn = targets.length > 1;
|
||||
const showAgentColumn = aggregateAgents || targets.length > 1;
|
||||
const header = [
|
||||
...(showAgentColumn ? ["Agent".padEnd(AGENT_PAD)] : []),
|
||||
"Kind".padEnd(KIND_PAD),
|
||||
|
||||
@@ -12,11 +12,13 @@ describe("session artifact helpers", () => {
|
||||
expect(isSessionArchiveArtifactName("abc.jsonl.reset.2026-01-01T00-00-00.000Z")).toBe(true);
|
||||
expect(isSessionArchiveArtifactName("abc.jsonl.bak.2026-01-01T00-00-00.000Z")).toBe(true);
|
||||
expect(isSessionArchiveArtifactName("sessions.json.bak.1737420882")).toBe(true);
|
||||
expect(isSessionArchiveArtifactName("keep.deleted.keep.jsonl")).toBe(false);
|
||||
expect(isSessionArchiveArtifactName("abc.jsonl")).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies primary transcript files", () => {
|
||||
expect(isPrimarySessionTranscriptFileName("abc.jsonl")).toBe(true);
|
||||
expect(isPrimarySessionTranscriptFileName("keep.deleted.keep.jsonl")).toBe(true);
|
||||
expect(isPrimarySessionTranscriptFileName("abc.jsonl.deleted.2026-01-01T00-00-00.000Z")).toBe(
|
||||
false,
|
||||
);
|
||||
@@ -31,5 +33,6 @@ describe("session artifact helpers", () => {
|
||||
const file = `abc.jsonl.deleted.${stamp}`;
|
||||
expect(parseSessionArchiveTimestamp(file, "deleted")).toBe(now);
|
||||
expect(parseSessionArchiveTimestamp(file, "reset")).toBeNull();
|
||||
expect(parseSessionArchiveTimestamp("keep.deleted.keep.jsonl", "deleted")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
export type SessionArchiveReason = "bak" | "reset" | "deleted";
|
||||
|
||||
const ARCHIVE_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(?:\.\d{3})?Z$/;
|
||||
const LEGACY_STORE_BACKUP_RE = /^sessions\.json\.bak\.\d+$/;
|
||||
|
||||
function hasArchiveSuffix(fileName: string, reason: SessionArchiveReason): boolean {
|
||||
const marker = `.${reason}.`;
|
||||
const index = fileName.lastIndexOf(marker);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
const raw = fileName.slice(index + marker.length);
|
||||
return ARCHIVE_TIMESTAMP_RE.test(raw);
|
||||
}
|
||||
|
||||
export function isSessionArchiveArtifactName(fileName: string): boolean {
|
||||
if (LEGACY_STORE_BACKUP_RE.test(fileName)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
fileName.includes(".deleted.") ||
|
||||
fileName.includes(".reset.") ||
|
||||
fileName.includes(".bak.") ||
|
||||
fileName.startsWith("sessions.json.bak.")
|
||||
hasArchiveSuffix(fileName, "deleted") ||
|
||||
hasArchiveSuffix(fileName, "reset") ||
|
||||
hasArchiveSuffix(fileName, "bak")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +59,9 @@ export function parseSessionArchiveTimestamp(
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (!ARCHIVE_TIMESTAMP_RE.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = Date.parse(restoreSessionArchiveTimestamp(raw));
|
||||
return Number.isNaN(timestamp) ? null : timestamp;
|
||||
}
|
||||
|
||||
95
src/config/sessions/disk-budget.test.ts
Normal file
95
src/config/sessions/disk-budget.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { formatSessionArchiveTimestamp } from "./artifacts.js";
|
||||
import { enforceSessionDiskBudget } from "./disk-budget.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
const createdDirs: string[] = [];
|
||||
|
||||
async function createCaseDir(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
createdDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(createdDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
createdDirs.length = 0;
|
||||
});
|
||||
|
||||
describe("enforceSessionDiskBudget", () => {
|
||||
it("does not treat referenced transcripts with marker-like session IDs as archived artifacts", async () => {
|
||||
const dir = await createCaseDir("openclaw-disk-budget-");
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
const sessionId = "keep.deleted.keep";
|
||||
const activeKey = "agent:main:main";
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
const store: Record<string, SessionEntry> = {
|
||||
[activeKey]: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.writeFile(transcriptPath, "x".repeat(256), "utf-8");
|
||||
|
||||
const result = await enforceSessionDiskBudget({
|
||||
store,
|
||||
storePath,
|
||||
activeSessionKey: activeKey,
|
||||
maintenance: {
|
||||
maxDiskBytes: 150,
|
||||
highWaterBytes: 100,
|
||||
},
|
||||
warnOnly: false,
|
||||
});
|
||||
|
||||
await expect(fs.stat(transcriptPath)).resolves.toBeDefined();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes true archived transcript artifacts while preserving referenced primary transcripts", async () => {
|
||||
const dir = await createCaseDir("openclaw-disk-budget-");
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
const sessionId = "keep";
|
||||
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||
const archivePath = path.join(
|
||||
dir,
|
||||
`old-session.jsonl.deleted.${formatSessionArchiveTimestamp(Date.now() - 24 * 60 * 60 * 1000)}`,
|
||||
);
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:main": {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8");
|
||||
await fs.writeFile(transcriptPath, "k".repeat(80), "utf-8");
|
||||
await fs.writeFile(archivePath, "a".repeat(260), "utf-8");
|
||||
|
||||
const result = await enforceSessionDiskBudget({
|
||||
store,
|
||||
storePath,
|
||||
maintenance: {
|
||||
maxDiskBytes: 300,
|
||||
highWaterBytes: 220,
|
||||
},
|
||||
warnOnly: false,
|
||||
});
|
||||
|
||||
await expect(fs.stat(transcriptPath)).resolves.toBeDefined();
|
||||
await expect(fs.stat(archivePath)).rejects.toThrow();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
removedFiles: 1,
|
||||
removedEntries: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user