Skip to content

Commit a54dfdd

Browse files
guazi04Qin Yang Mou
authored andcommitted
feat(session): add lifecycle management — storage reclamation, CLI commands, VACUUM support
- Clear tool output/attachments after compaction to reclaim storage (conservative mode, configurable via compaction.reclaim) - Add retroactive reclamation pass for previously-pruned parts - Expose session archive/unarchive in CLI - Add `session stats` command showing DB size and session/message/part counts - Add `session prune` command with --older-than, --children, --vacuum, --dry-run - Add Database.vacuum() and Database.checkpoint() for disk space reclamation - Fix cross-project child session cascade deletion via project-agnostic childrenAll() Closes #16101
1 parent 4f982dd commit a54dfdd

File tree

5 files changed

+284
-6
lines changed

5 files changed

+284
-6
lines changed

packages/opencode/src/cli/cmd/session.ts

Lines changed: 238 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { Process } from "../../util/process"
1010
import { EOL } from "os"
1111
import path from "path"
1212
import { which } from "../../util/which"
13+
import { Database, sql, eq, or, lt, isNull, not, and, desc } from "../../storage/db"
14+
import { SessionTable } from "../../session/session.sql"
1315

1416
function pagerCmd(): string[] {
1517
const lessOptions = ["-R", "-S"]
@@ -41,7 +43,15 @@ function pagerCmd(): string[] {
4143
export const SessionCommand = cmd({
4244
command: "session",
4345
describe: "manage sessions",
44-
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
46+
builder: (yargs: Argv) =>
47+
yargs
48+
.command(SessionListCommand)
49+
.command(SessionDeleteCommand)
50+
.command(SessionArchiveCommand)
51+
.command(SessionUnarchiveCommand)
52+
.command(SessionStatsCommand)
53+
.command(SessionPruneCommand)
54+
.demandCommand(),
4555
async handler() {},
4656
})
4757

@@ -155,3 +165,230 @@ function formatSessionJSON(sessions: Session.Info[]): string {
155165
}))
156166
return JSON.stringify(jsonData, null, 2)
157167
}
168+
169+
export const SessionArchiveCommand = cmd({
170+
command: "archive <sessionID>",
171+
describe: "archive a session",
172+
builder: (yargs: Argv) => {
173+
return yargs.positional("sessionID", {
174+
describe: "session ID to archive",
175+
type: "string",
176+
demandOption: true,
177+
})
178+
},
179+
handler: async (args) => {
180+
await bootstrap(process.cwd(), async () => {
181+
try {
182+
await Session.get(args.sessionID)
183+
} catch {
184+
UI.error(`Session not found: ${args.sessionID}`)
185+
process.exit(1)
186+
}
187+
await Session.setArchived({ sessionID: args.sessionID, time: Date.now() })
188+
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} archived` + UI.Style.TEXT_NORMAL)
189+
})
190+
},
191+
})
192+
193+
export const SessionUnarchiveCommand = cmd({
194+
command: "unarchive <sessionID>",
195+
describe: "unarchive a session",
196+
builder: (yargs: Argv) => {
197+
return yargs.positional("sessionID", {
198+
describe: "session ID to unarchive",
199+
type: "string",
200+
demandOption: true,
201+
})
202+
},
203+
handler: async (args) => {
204+
await bootstrap(process.cwd(), async () => {
205+
try {
206+
await Session.get(args.sessionID)
207+
} catch {
208+
UI.error(`Session not found: ${args.sessionID}`)
209+
process.exit(1)
210+
}
211+
await Session.setArchived({ sessionID: args.sessionID, time: undefined })
212+
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} unarchived` + UI.Style.TEXT_NORMAL)
213+
})
214+
},
215+
})
216+
217+
function formatSize(bytes: number): string {
218+
if (bytes >= 1_073_741_824) return (bytes / 1_073_741_824).toFixed(1) + " GB"
219+
if (bytes >= 1_048_576) return (bytes / 1_048_576).toFixed(1) + " MB"
220+
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"
221+
return bytes + " B"
222+
}
223+
224+
export const SessionStatsCommand = cmd({
225+
command: "stats",
226+
describe: "show session storage statistics",
227+
builder: (yargs: Argv) => yargs,
228+
handler: async () => {
229+
await bootstrap(process.cwd(), async () => {
230+
const size = Number(Filesystem.stat(Database.Path)?.size ?? 0)
231+
const wal = Number(Filesystem.stat(Database.Path + "-wal")?.size ?? 0)
232+
233+
const counts = Database.use((db) =>
234+
db
235+
.get<{
236+
roots: number
237+
children: number
238+
archived: number
239+
messages: number
240+
parts: number
241+
}>(
242+
sql`SELECT
243+
(SELECT COUNT(*) FROM session WHERE parent_id IS NULL) as roots,
244+
(SELECT COUNT(*) FROM session WHERE parent_id IS NOT NULL) as children,
245+
(SELECT COUNT(*) FROM session WHERE time_archived IS NOT NULL) as archived,
246+
(SELECT COUNT(*) FROM message) as messages,
247+
(SELECT COUNT(*) FROM part) as parts`,
248+
)
249+
)
250+
251+
const r = counts?.roots ?? 0
252+
const c = counts?.children ?? 0
253+
254+
console.log("Session Storage Statistics")
255+
console.log("─".repeat(40))
256+
console.log(`Database size: ${formatSize(size)}`)
257+
if (wal > 0) console.log(`WAL file size: ${formatSize(wal)}`)
258+
console.log(`Total sessions: ${r + c} (${r} root, ${c} child)`)
259+
console.log(`Archived sessions: ${counts?.archived ?? 0}`)
260+
console.log(`Total messages: ${(counts?.messages ?? 0).toLocaleString()}`)
261+
console.log(`Total parts: ${(counts?.parts ?? 0).toLocaleString()}`)
262+
})
263+
},
264+
})
265+
266+
export const SessionPruneCommand = cmd({
267+
command: "prune",
268+
describe: "delete old and archived sessions to reclaim storage",
269+
builder: (yargs: Argv) =>
270+
yargs
271+
.option("older-than", {
272+
describe: "prune sessions inactive for N days (default: 30)",
273+
type: "number",
274+
default: 30,
275+
})
276+
.option("children", {
277+
describe: "also prune child sessions independently",
278+
type: "boolean",
279+
default: false,
280+
})
281+
.option("vacuum", {
282+
describe: "run VACUUM after pruning",
283+
type: "boolean",
284+
default: false,
285+
})
286+
.option("dry-run", {
287+
describe: "show what would be pruned without deleting",
288+
type: "boolean",
289+
default: false,
290+
}),
291+
handler: async (args) => {
292+
await bootstrap(process.cwd(), async () => {
293+
const cutoff = Date.now() - args.olderThan * 86_400_000
294+
const BATCH = 100
295+
const candidates: { id: string; title: string; archived: boolean; parent: boolean }[] = []
296+
297+
// paginate through all prunable sessions in batches
298+
let offset = 0
299+
while (true) {
300+
const rows = Database.use((db) => {
301+
const conditions = [
302+
or(
303+
not(isNull(SessionTable.time_archived)),
304+
lt(SessionTable.time_updated, cutoff),
305+
),
306+
]
307+
if (!args.children) {
308+
conditions.push(isNull(SessionTable.parent_id))
309+
}
310+
return db
311+
.select({
312+
id: SessionTable.id,
313+
title: SessionTable.title,
314+
time_archived: SessionTable.time_archived,
315+
parent_id: SessionTable.parent_id,
316+
})
317+
.from(SessionTable)
318+
.where(and(...conditions))
319+
.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
320+
.limit(BATCH)
321+
.offset(offset)
322+
.all()
323+
})
324+
if (rows.length === 0) break
325+
for (const row of rows) {
326+
candidates.push({
327+
id: row.id,
328+
title: row.title,
329+
archived: row.time_archived !== null,
330+
parent: row.parent_id === null,
331+
})
332+
}
333+
if (rows.length < BATCH) break
334+
offset += BATCH
335+
}
336+
337+
if (candidates.length === 0) {
338+
UI.println("No sessions to prune")
339+
return
340+
}
341+
342+
// sort roots before children to avoid double-delete
343+
candidates.sort((a, b) => (a.parent === b.parent ? 0 : a.parent ? -1 : 1))
344+
345+
if (args.dryRun) {
346+
UI.println(`Would prune ${candidates.length} session(s):`)
347+
for (const s of candidates) {
348+
const tag = s.archived ? " [archived]" : ""
349+
UI.println(` ${s.id} ${Locale.truncate(s.title, 40)}${tag}`)
350+
}
351+
return
352+
}
353+
354+
const before = Number(Filesystem.stat(Database.Path)?.size ?? 0)
355+
const deleted = new Set<string>()
356+
for (const s of candidates) {
357+
if (deleted.has(s.id)) continue
358+
const descendants = collectDescendants(s.id)
359+
await Session.remove(s.id)
360+
deleted.add(s.id)
361+
for (const d of descendants) deleted.add(d)
362+
}
363+
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Pruned ${deleted.size} session(s)` + UI.Style.TEXT_NORMAL)
364+
365+
if (args.vacuum) {
366+
try {
367+
Database.vacuum()
368+
const after = Number(Filesystem.stat(Database.Path)?.size ?? 0)
369+
const freed = before - after
370+
if (freed > 0) UI.println(`Reclaimed ${formatSize(freed)}`)
371+
else UI.println("Database vacuumed")
372+
} catch {
373+
UI.error("Database is busy or locked — try again when no sessions are active")
374+
}
375+
}
376+
})
377+
},
378+
})
379+
380+
function collectDescendants(id: string): string[] {
381+
const rows = Database.use((db) =>
382+
db
383+
.select({ id: SessionTable.id })
384+
.from(SessionTable)
385+
.where(eq(SessionTable.parent_id, id))
386+
.all(),
387+
)
388+
const result: string[] = []
389+
for (const row of rows) {
390+
result.push(row.id)
391+
result.push(...collectDescendants(row.id))
392+
}
393+
return result
394+
}

packages/opencode/src/config/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,7 @@ export namespace Config {
11391139
.object({
11401140
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
11411141
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
1142+
reclaim: z.boolean().optional().describe("Clear old tool outputs from storage after compaction (default: true)"),
11421143
reserved: z
11431144
.number()
11441145
.int()

packages/opencode/src/session/compaction.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export namespace SessionCompaction {
6262
const msgs = await Session.messages({ sessionID: input.sessionID })
6363
let total = 0
6464
let pruned = 0
65-
const toPrune = []
65+
const toPrune: MessageV2.ToolPart[] = []
6666
let turns = 0
6767

6868
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
@@ -88,13 +88,18 @@ export namespace SessionCompaction {
8888
}
8989
log.info("found", { pruned, total })
9090
if (pruned > PRUNE_MINIMUM) {
91+
const reclaim = config.compaction?.reclaim !== false
9192
for (const part of toPrune) {
9293
if (part.state.status === "completed") {
9394
part.state.time.compacted = Date.now()
95+
if (reclaim) {
96+
part.state.output = "[reclaimed]"
97+
part.state.attachments = []
98+
}
9499
await Session.updatePart(part)
95100
}
96101
}
97-
log.info("pruned", { count: toPrune.length })
102+
log.info("pruned", { count: toPrune.length, reclaim })
98103
}
99104
}
100105

@@ -290,6 +295,21 @@ When constructing the summary, try to stick to this template:
290295
}
291296
if (processor.message.error) return "stop"
292297
Bus.publish(Event.Compacted, { sessionID: input.sessionID })
298+
// retroactive reclamation: clear output/attachments from previously compacted parts
299+
const cfg = await Config.get()
300+
if (cfg.compaction?.reclaim !== false) {
301+
for (const msg of input.messages) {
302+
for (const part of msg.parts) {
303+
if (part.type !== "tool") continue
304+
if (part.state.status !== "completed") continue
305+
if (!part.state.time.compacted) continue
306+
if (part.state.output === "[reclaimed]") continue
307+
part.state.output = "[reclaimed]"
308+
part.state.attachments = []
309+
await Session.updatePart(part)
310+
}
311+
}
312+
}
293313
return "continue"
294314
}
295315

packages/opencode/src/session/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
1010
import { Identifier } from "../id/id"
1111
import { Installation } from "../installation"
1212

13-
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
13+
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
1414
import type { SQL } from "../storage/db"
1515
import { SessionTable, MessageTable, PartTable } from "./session.sql"
1616
import { ProjectTable } from "../project/project.sql"
@@ -654,11 +654,18 @@ export namespace Session {
654654
return rows.map(fromRow)
655655
})
656656

657+
// finds ALL children regardless of project — used by remove() for safe cascading
658+
function childrenAll(parentID: string) {
659+
const rows = Database.use((db) =>
660+
db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all(),
661+
)
662+
return rows.map(fromRow)
663+
}
664+
657665
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
658-
const project = Instance.project
659666
try {
660667
const session = await get(sessionID)
661-
for (const child of await children(sessionID)) {
668+
for (const child of childrenAll(sessionID)) {
662669
await remove(child.id)
663670
}
664671
await unshare(sessionID).catch(() => {})

packages/opencode/src/storage/db.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,19 @@ export namespace Database {
124124
Client.reset()
125125
}
126126

127+
export function vacuum() {
128+
const sqlite = state.sqlite
129+
if (!sqlite) return
130+
sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)")
131+
sqlite.run("VACUUM")
132+
}
133+
134+
export function checkpoint() {
135+
const sqlite = state.sqlite
136+
if (!sqlite) return
137+
sqlite.run("PRAGMA wal_checkpoint(TRUNCATE)")
138+
}
139+
127140
export type TxOrDb = Transaction | Client
128141

129142
const ctx = Context.create<{

0 commit comments

Comments
 (0)