Skip to content

Commit fe651de

Browse files
committed
feat: prune stale session files after one week
Delete session persistence files when lastActivityAt is older than seven days and add coverage for stale/recent retention behavior.
1 parent 00e2e0b commit fe651de

File tree

2 files changed

+108
-2
lines changed

2 files changed

+108
-2
lines changed

packages/config/local/sessions.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const homedir = typeof os.homedir === "function" ? os.homedir : () => "";
1515
const ODE_CONFIG_DIR = join(homedir(), ".config", "ode");
1616
const SESSIONS_DIR = join(ODE_CONFIG_DIR, "sessions");
1717
const SESSION_SAVE_DEBOUNCE_MS = 5000;
18+
const SESSION_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
1819

1920
export interface TrackedTool {
2021
id: string;
@@ -101,6 +102,21 @@ function getSessionFilePath(sessionKey: string): string {
101102
return join(SESSIONS_DIR, `${safeKey}.json`);
102103
}
103104

105+
function getSessionLastActiveAt(session: PersistedSession): number {
106+
if (Number.isFinite(session.lastActivityAt)) {
107+
return session.lastActivityAt;
108+
}
109+
if (Number.isFinite(session.createdAt)) {
110+
return session.createdAt;
111+
}
112+
return 0;
113+
}
114+
115+
function isSessionExpired(session: PersistedSession, now = Date.now()): boolean {
116+
const lastActiveAt = getSessionLastActiveAt(session);
117+
return now - lastActiveAt >= SESSION_RETENTION_MS;
118+
}
119+
104120
function sanitizeSessionForStorage(session: PersistedSession): PersistedSession {
105121
const snapshot = structuredClone(session);
106122
if (snapshot.activeRequest) {
@@ -158,7 +174,12 @@ export function loadSession(channelId: string, threadId: string): PersistedSessi
158174

159175
// Check cache first
160176
if (activeSessions.has(sessionKey)) {
161-
return activeSessions.get(sessionKey)!;
177+
const cached = activeSessions.get(sessionKey)!;
178+
if (isSessionExpired(cached)) {
179+
deleteSession(channelId, threadId);
180+
return null;
181+
}
182+
return cached;
162183
}
163184

164185
const filePath = getSessionFilePath(sessionKey);
@@ -169,6 +190,10 @@ export function loadSession(channelId: string, threadId: string): PersistedSessi
169190
try {
170191
const data = readFileSync(filePath, "utf-8");
171192
const session = JSON.parse(data) as PersistedSession;
193+
if (isSessionExpired(session)) {
194+
deleteSession(channelId, threadId);
195+
return null;
196+
}
172197
if (session.activeRequest) {
173198
const active = session.activeRequest as ActiveRequest & {
174199
settingsChannelId?: string;
@@ -340,7 +365,11 @@ export function loadAllSessions(): PersistedSession[] {
340365
ensureSessionsDir();
341366
const sessionsByKey = new Map<string, PersistedSession>();
342367

343-
for (const [sessionKey, session] of activeSessions.entries()) {
368+
for (const [sessionKey, session] of Array.from(activeSessions.entries())) {
369+
if (isSessionExpired(session)) {
370+
deleteSession(session.channelId, session.threadId);
371+
continue;
372+
}
344373
sessionsByKey.set(sessionKey, session);
345374
}
346375

@@ -351,6 +380,10 @@ export function loadAllSessions(): PersistedSession[] {
351380
try {
352381
const data = readFileSync(filePath, "utf-8");
353382
const session = JSON.parse(data) as PersistedSession;
383+
if (isSessionExpired(session)) {
384+
deleteSession(session.channelId, session.threadId);
385+
continue;
386+
}
354387
const sessionKey = getSessionKey(session.channelId, session.threadId);
355388
if (!sessionsByKey.has(sessionKey)) {
356389
sessionsByKey.set(sessionKey, session);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "bun:test";
2+
import * as fs from "fs";
3+
import * as os from "os";
4+
import * as path from "path";
5+
import { deleteSession, loadSession } from "@/config/local/sessions";
6+
7+
const SESSIONS_DIR = path.join(os.homedir(), ".config", "ode", "sessions");
8+
9+
function getSessionFilePath(channelId: string, threadId: string): string {
10+
const sessionKey = `${channelId}-${threadId}`;
11+
const safeKey = sessionKey.replace(/[^a-zA-Z0-9-]/g, "_");
12+
return path.join(SESSIONS_DIR, `${safeKey}.json`);
13+
}
14+
15+
function writeSessionFixture(params: {
16+
channelId: string;
17+
threadId: string;
18+
createdAt: number;
19+
lastActivityAt: number;
20+
}): string {
21+
const { channelId, threadId, createdAt, lastActivityAt } = params;
22+
const filePath = getSessionFilePath(channelId, threadId);
23+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
24+
fs.writeFileSync(filePath, JSON.stringify({
25+
sessionId: `session-${threadId}`,
26+
channelId,
27+
threadId,
28+
workingDirectory: "/tmp",
29+
createdAt,
30+
lastActivityAt,
31+
}, null, 2));
32+
return filePath;
33+
}
34+
35+
describe("session retention", () => {
36+
it("deletes session file when lastActivityAt is older than one week", () => {
37+
const channelId = "RETENTION-C1";
38+
const threadId = "RETENTION-T1";
39+
const now = Date.now();
40+
const staleAt = now - 8 * 24 * 60 * 60 * 1000;
41+
const filePath = writeSessionFixture({
42+
channelId,
43+
threadId,
44+
createdAt: staleAt,
45+
lastActivityAt: staleAt,
46+
});
47+
48+
expect(fs.existsSync(filePath)).toBeTrue();
49+
expect(loadSession(channelId, threadId)).toBeNull();
50+
expect(fs.existsSync(filePath)).toBeFalse();
51+
52+
deleteSession(channelId, threadId);
53+
});
54+
55+
it("keeps session file when lastActivityAt is within one week", () => {
56+
const channelId = "RETENTION-C2";
57+
const threadId = "RETENTION-T2";
58+
const now = Date.now();
59+
const recentAt = now - 2 * 24 * 60 * 60 * 1000;
60+
const filePath = writeSessionFixture({
61+
channelId,
62+
threadId,
63+
createdAt: recentAt,
64+
lastActivityAt: recentAt,
65+
});
66+
67+
const loaded = loadSession(channelId, threadId);
68+
expect(loaded).not.toBeNull();
69+
expect(fs.existsSync(filePath)).toBeTrue();
70+
71+
deleteSession(channelId, threadId);
72+
});
73+
});

0 commit comments

Comments
 (0)