Skip to content

Commit f96994b

Browse files
authored
Merge pull request #560 from cloudyli/codex/pr3-session-compaction
fix: wire SQLite session history compaction
2 parents 129878a + 5bad4f4 commit f96994b

6 files changed

Lines changed: 731 additions & 15 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# SQLite Session History Maintenance
2+
3+
Routa 本地 SQLite 长时间运行后,ACP 会话历史可能同时占用:
4+
5+
- `session_messages`:按事件追加的会话消息表。
6+
- `acp_sessions.message_history`:兼容旧读取路径的 JSON 历史列。
7+
- `routa.db-wal`:SQLite WAL 文件。
8+
9+
维护入口:
10+
11+
```bash
12+
npm run db:sqlite:compact-sessions -- --db ./routa.db --dry-run --json
13+
npm run db:sqlite:compact-sessions -- --db ./routa.db --apply --checkpoint
14+
```
15+
16+
如需在确认服务已停止后回收更多磁盘空间:
17+
18+
```bash
19+
npm run db:sqlite:compact-sessions -- --db ./routa.db --apply --checkpoint --vacuum
20+
```
21+
22+
行为边界:
23+
24+
- 默认 `dry-run`,只报告候选 session、可合并 chunk 和可删除行数。
25+
- `--apply` 才会写入数据库。
26+
- 默认保护 60 分钟内更新过的 session,也保护 `lease_expires_at` 尚未过期的 session。
27+
- 可以用 `--active-session <id>` 显式保护正在运行的 session。
28+
- 只合并连续的 `agent_message_chunk``agent_message`
29+
- 不删除 artifact、task completion summary、verification report 或业务对象。
30+
- compaction 不更新 `acp_sessions.updated_at`,避免维护操作污染会话活跃时间线。
31+
- `--checkpoint` 会执行 `PRAGMA wal_checkpoint(TRUNCATE)`
32+
- `--vacuum` 会执行 `VACUUM`,建议只在 Routa 服务停止后使用,避免 Windows SQLite 文件锁问题。

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"db:sqlite:generate": "drizzle-kit generate --config=drizzle-sqlite.config.ts",
9494
"db:sqlite:migrate": "drizzle-kit migrate --config=drizzle-sqlite.config.ts",
9595
"db:sqlite:push": "drizzle-kit push --config=drizzle-sqlite.config.ts",
96+
"db:sqlite:compact-sessions": "node --import tsx scripts/maintenance/compact-session-history.ts",
9697
"api:check": "node --import tsx scripts/fitness/check-api-parity.ts",
9798
"api:check:json": "node --import tsx scripts/fitness/check-api-parity.ts --json",
9899
"api:check:fix": "node --import tsx scripts/fitness/check-api-parity.ts --fix-hint",
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import BetterSqlite3 from "better-sqlite3";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import { runSessionHistoryMaintenance } from "../maintenance/compact-session-history";
7+
8+
const tempDirs: string[] = [];
9+
type CountRow = { count: number };
10+
type PayloadRow = { payload: string };
11+
type SessionHistoryRow = { message_history: string };
12+
13+
function createFixtureDb(): string {
14+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "routa-session-compact-"));
15+
tempDirs.push(dir);
16+
const dbPath = path.join(dir, "routa.db");
17+
const sqlite = new BetterSqlite3(dbPath);
18+
const old = Date.now() - 10 * 24 * 60 * 60 * 1000;
19+
const active = Date.now();
20+
sqlite.exec(`
21+
CREATE TABLE acp_sessions (
22+
id TEXT PRIMARY KEY,
23+
message_history TEXT DEFAULT '[]',
24+
updated_at INTEGER,
25+
lease_expires_at INTEGER
26+
);
27+
CREATE TABLE session_messages (
28+
id TEXT PRIMARY KEY,
29+
session_id TEXT NOT NULL,
30+
message_index INTEGER NOT NULL,
31+
event_type TEXT NOT NULL,
32+
payload TEXT NOT NULL,
33+
created_at INTEGER
34+
);
35+
`);
36+
sqlite.prepare("INSERT INTO acp_sessions (id, message_history, updated_at, lease_expires_at) VALUES (?, ?, ?, ?)").run(
37+
"old-session",
38+
JSON.stringify([
39+
{ sessionId: "old-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hel" } } },
40+
{ sessionId: "old-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "lo" } } },
41+
{ sessionId: "old-session", update: { sessionUpdate: "turn_complete" } },
42+
]),
43+
old,
44+
null,
45+
);
46+
sqlite.prepare("INSERT INTO acp_sessions (id, message_history, updated_at, lease_expires_at) VALUES (?, ?, ?, ?)").run(
47+
"active-session",
48+
JSON.stringify([
49+
{ sessionId: "active-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Act" } } },
50+
{ sessionId: "active-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ive" } } },
51+
]),
52+
active,
53+
Date.now() + 60_000,
54+
);
55+
sqlite.prepare("INSERT INTO acp_sessions (id, message_history, updated_at, lease_expires_at) VALUES (?, ?, ?, ?)").run(
56+
"invalid-session",
57+
JSON.stringify([]),
58+
old,
59+
null,
60+
);
61+
const insertMessage = sqlite.prepare(
62+
"INSERT INTO session_messages (id, session_id, message_index, event_type, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)",
63+
);
64+
insertMessage.run("m1", "old-session", 0, "agent_message_chunk", JSON.stringify({ type: "agent_message_chunk", text: "A" }), old);
65+
insertMessage.run("m2", "old-session", 1, "agent_message_chunk", JSON.stringify({ type: "agent_message_chunk", text: "B" }), old);
66+
insertMessage.run("m3", "active-session", 0, "agent_message_chunk", JSON.stringify({ sessionId: "active-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "C" } } }), old);
67+
insertMessage.run("m4", "active-session", 1, "agent_message_chunk", JSON.stringify({ sessionId: "active-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "D" } } }), old);
68+
insertMessage.run("m5", "invalid-session", 0, "agent_message_chunk", JSON.stringify({ type: "agent_message", text: "Keep" }), old);
69+
insertMessage.run("m6", "invalid-session", 1, "agent_message_chunk", JSON.stringify({ type: "agent_message", text: "Me" }), old);
70+
sqlite.close();
71+
return dbPath;
72+
}
73+
74+
afterEach(() => {
75+
for (const dir of tempDirs.splice(0)) {
76+
fs.rmSync(dir, { recursive: true, force: true });
77+
}
78+
});
79+
80+
describe("compact-session-history maintenance script", () => {
81+
it("reports dry-run changes without writing", () => {
82+
const dbPath = createFixtureDb();
83+
const summary = runSessionHistoryMaintenance({
84+
dbPath,
85+
mode: "dry-run",
86+
sessionCutoffDays: 7,
87+
activeWindowMinutes: 60,
88+
activeSessionIds: new Set(),
89+
checkpoint: true,
90+
vacuum: true,
91+
json: true,
92+
});
93+
94+
expect(summary.sessionMessages.mergedGroups).toBe(1);
95+
expect(summary.sessionMessages.deletedRows).toBe(1);
96+
expect(summary.acpSessions.compactedSessions).toBe(1);
97+
expect(summary.protectedActiveSessions).toEqual(["active-session"]);
98+
99+
const sqlite = new BetterSqlite3(dbPath);
100+
expect(sqlite.prepare("SELECT COUNT(*) AS count FROM session_messages").get()).toEqual({ count: 6 });
101+
sqlite.close();
102+
});
103+
104+
it("applies compaction and skips active sessions", () => {
105+
const dbPath = createFixtureDb();
106+
const summary = runSessionHistoryMaintenance({
107+
dbPath,
108+
mode: "apply",
109+
sessionCutoffDays: 7,
110+
activeWindowMinutes: 60,
111+
activeSessionIds: new Set(),
112+
checkpoint: false,
113+
vacuum: false,
114+
json: true,
115+
});
116+
117+
expect(summary.sessionMessages.deletedRows).toBe(1);
118+
const sqlite = new BetterSqlite3(dbPath);
119+
expect(sqlite.prepare("SELECT COUNT(*) AS count FROM session_messages").get() as CountRow).toEqual({ count: 5 });
120+
const oldPayloadRow = sqlite.prepare("SELECT payload FROM session_messages WHERE id = ?").get("m1") as PayloadRow;
121+
const oldPayload = JSON.parse(oldPayloadRow.payload);
122+
expect(oldPayload.update.sessionUpdate).toBe("agent_message");
123+
expect(oldPayload.update.content.text).toBe("AB");
124+
expect(oldPayload.type).toBe("agent_message");
125+
expect(oldPayload.text).toBe("AB");
126+
127+
const activePayloadRow = sqlite.prepare("SELECT payload FROM session_messages WHERE id = ?").get("m3") as PayloadRow;
128+
const activePayload = JSON.parse(activePayloadRow.payload);
129+
expect(activePayload.update.sessionUpdate).toBe("agent_message_chunk");
130+
131+
const invalidPayloadRow = sqlite.prepare("SELECT event_type, payload FROM session_messages WHERE id = ?").get("m6") as {
132+
event_type: string;
133+
payload: string;
134+
};
135+
expect(invalidPayloadRow.event_type).toBe("agent_message_chunk");
136+
expect(JSON.parse(invalidPayloadRow.payload).text).toBe("Me");
137+
138+
const oldSession = sqlite.prepare("SELECT message_history FROM acp_sessions WHERE id = ?").get("old-session") as SessionHistoryRow;
139+
expect(JSON.parse(oldSession.message_history)).toHaveLength(2);
140+
const activeSession = sqlite.prepare("SELECT message_history FROM acp_sessions WHERE id = ?").get("active-session") as SessionHistoryRow;
141+
expect(JSON.parse(activeSession.message_history)).toHaveLength(2);
142+
sqlite.close();
143+
});
144+
});

0 commit comments

Comments
 (0)