Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/opencode/src/cli/cmd/session-prune-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export type PruneSession = {
id: string
parentID?: string
title?: string
share?: { url: string }
time: {
updated: number
compacting?: number
}
}

export type PruneStatus = {
type: string
}

const durationUnits = {
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
} as const

type DurationUnit = keyof typeof durationUnits

export type PruneCandidate = {
root: PruneSession
sessions: PruneSession[]
}

export function parsePruneDuration(value: string): number | undefined {
const match = /^(\d+)([mhdw])$/.exec(value.trim())
if (!match) return undefined

const amount = Number(match[1])
const unit = match[2] as DurationUnit
if (!Number.isSafeInteger(amount) || amount <= 0) return undefined

const duration = amount * durationUnits[unit]
if (!Number.isSafeInteger(duration)) return undefined
return duration
}

function collectFamily(
root: PruneSession,
children: ReadonlyMap<string, PruneSession[]>,
visited = new Set<string>(),
): PruneSession[] {
if (visited.has(root.id)) return []
visited.add(root.id)
return [root, ...(children.get(root.id) ?? []).flatMap((child) => collectFamily(child, children, visited))]
}

function sessionFamilies(sessions: PruneSession[]) {
const children = new Map<string, PruneSession[]>()
for (const session of sessions) {
if (!session.parentID) continue
const entries = children.get(session.parentID) ?? []
entries.push(session)
children.set(session.parentID, entries)
}

return sessions
.filter((session) => !session.parentID)
.map((root) => ({ root, sessions: collectFamily(root, children) }))
}

export function selectPruneCandidates(
sessions: PruneSession[],
cutoff: number,
active: ReadonlyMap<string, PruneStatus>,
): PruneCandidate[] {
return sessionFamilies(sessions).filter(({ root, sessions: family }) => {
if (root.share || family.some((session) => session.share)) return false
if (family.some((session) => session.time.compacting !== undefined)) return false
if (
family.some((session) => {
const status = active.get(session.id)
return status !== undefined && status.type !== "idle"
})
)
return false
return family.every((session) => session.time.updated < cutoff)
})
}
95 changes: 95 additions & 0 deletions packages/opencode/src/cli/cmd/session-prune.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Argv } from "yargs"
import { confirm, isCancel } from "@clack/prompts"
import { Clock, Effect, Exit } from "effect"
import { Session } from "@/session/session"
import { SessionStatus } from "@/session/status"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import { Locale } from "@/util/locale"

import { parsePruneDuration, selectPruneCandidates } from "./session-prune-core"

export const SessionPruneCommand = effectCmd({
command: "prune <duration>",
describe: "delete sessions older than a duration",
builder: (yargs: Argv) =>
yargs
.positional("duration", {
describe: "age threshold such as 12h, 30d, or 2w",
type: "string",
demandOption: true,
})
.option("dry-run", {
describe: "show candidates without deleting them",
type: "boolean",
default: false,
})
.option("force", {
alias: "f",
describe: "skip confirmation prompts",
type: "boolean",
default: false,
}),
handler: Effect.fn("Cli.session.prune")(function* (args) {
const duration = parsePruneDuration(args.duration as string)
if (duration === undefined) return yield* fail("Invalid duration. Use a positive value such as 12h, 30d, or 2w.")

const cutoff = (yield* Clock.currentTimeMillis) - duration
const session = yield* Session.Service
const status = yield* SessionStatus.Service
const sessions = yield* session.listAll()
const candidates = selectPruneCandidates(sessions, cutoff, yield* status.list())

if (candidates.length === 0) {
UI.println("No sessions match the prune criteria.")
return
}

UI.println(`Prune cutoff: ${new Date(cutoff).toISOString()}`)
UI.println(`Candidates: ${candidates.length} session ${candidates.length === 1 ? "family" : "families"}`)
for (const candidate of candidates) {
UI.println(
` ${candidate.root.id} ${Locale.truncate(candidate.root.title ?? "Untitled session", 60)} ${candidate.sessions.length} session(s)`,
)
}

if (args.dryRun) {
UI.println("Dry run - no sessions deleted.")
return
}

if (!args.force) {
if (!process.stdin.isTTY || !process.stdout.isTTY)
return yield* fail("Refusing to delete sessions without --force in non-interactive mode.")

const answer = yield* Effect.promise(() =>
confirm({
message: `Delete ${candidates.length} session ${candidates.length === 1 ? "family" : "families"}?`,
initialValue: false,
}),
)
if (isCancel(answer) || !answer) {
UI.println("Aborted.")
return
}
}

const selected = new Set(candidates.map((candidate) => candidate.root.id))
const current = selectPruneCandidates(yield* session.listAll(), cutoff, yield* status.list()).filter((candidate) =>
selected.has(candidate.root.id),
)
let deleted = 0
let deletedSessions = 0
for (const candidate of current) {
const result = yield* session.remove(candidate.root.id).pipe(Effect.exit)
if (Exit.isSuccess(result)) {
deleted++
deletedSessions += candidate.sessions.length
continue
}
UI.println(`Could not delete ${candidate.root.id}; it may have changed or disappeared.`)
}

UI.println(`Deleted ${deleted} session ${deleted === 1 ? "family" : "families"} (${deletedSessions} sessions).`)
}),
})
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { NotFoundError } from "@/storage/storage"
import { EOL } from "os"
import path from "path"
import { which } from "@opencode-ai/core/util/which"
import { SessionPruneCommand } from "./session-prune"

function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
Expand Down Expand Up @@ -44,7 +45,8 @@ function pagerCmd(): string[] {
export const SessionCommand = cmd({
command: "session",
describe: "manage sessions",
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionDeleteCommand).demandCommand(),
builder: (yargs: Argv) =>
yargs.command(SessionListCommand).command(SessionDeleteCommand).command(SessionPruneCommand).demandCommand(),
async handler() {},
})

Expand Down
20 changes: 16 additions & 4 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ export type NotFound = NotFoundError

export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly listAll: (input?: Omit<ListInput, "limit">) => Effect.Effect<Info[]>
readonly listGlobal: (input?: GlobalListInput) => Effect.Effect<GlobalInfo[]>
readonly create: (input?: {
parentID?: SessionID
Expand Down Expand Up @@ -549,6 +550,17 @@ const layer: Layer.Layer<
projectID: ctx.project.id,
experimentalWorkspaces: flags.experimentalWorkspaces,
...input,
limit: input?.limit ?? 100,
})
})

const listAll = Effect.fn("Session.listAll")(function* (input?: Omit<ListInput, "limit">) {
const ctx = yield* InstanceState.context
return yield* listByProject(db, {
projectID: ctx.project.id,
experimentalWorkspaces: flags.experimentalWorkspaces,
...input,
limit: undefined,
})
})

Expand Down Expand Up @@ -905,6 +917,7 @@ const layer: Layer.Layer<

return Service.of({
list,
listAll,
listGlobal,
create,
fork,
Expand Down Expand Up @@ -992,14 +1005,13 @@ function listByProject(
conditions.push(like(SessionTable.title, `%${input.search}%`))
}

const limit = input.limit ?? 100

return db
const query = db
.select()
.from(SessionTable)
.where(and(...conditions))
.orderBy(desc(SessionTable.time_updated))
.limit(limit)

return (input.limit === undefined ? query : query.limit(input.limit))
.all()
.pipe(
Effect.orDie,
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/test/cli/session-prune.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import type { PruneSession, PruneStatus } from "@/cli/cmd/session-prune-core"
import { parsePruneDuration, selectPruneCandidates } from "@/cli/cmd/session-prune-core"

function session(id: string, updated: number, parentID?: string, extra: Partial<PruneSession> = {}): PruneSession {
return {
id,
...(parentID ? { parentID } : {}),
time: { updated },
...extra,
}
}

describe("parsePruneDuration", () => {
test("parses supported duration suffixes", () => {
expect(parsePruneDuration("30m")).toBe(30 * 60 * 1000)
expect(parsePruneDuration("12h")).toBe(12 * 60 * 60 * 1000)
expect(parsePruneDuration("30d")).toBe(30 * 24 * 60 * 60 * 1000)
expect(parsePruneDuration("2w")).toBe(14 * 24 * 60 * 60 * 1000)
})

test("rejects invalid or unsafe durations", () => {
expect(parsePruneDuration("0d")).toBeUndefined()
expect(parsePruneDuration("-1d")).toBeUndefined()
expect(parsePruneDuration("1y")).toBeUndefined()
expect(parsePruneDuration("1.5d")).toBeUndefined()
expect(parsePruneDuration("999999999999999999999d")).toBeUndefined()
})
})

describe("selectPruneCandidates", () => {
test("selects an old root family and returns its descendants", () => {
const root = session("root", 10)
const child = session("child", 20, "root")
const result = selectPruneCandidates([child, root], 100, new Map())

expect(result).toHaveLength(1)
expect(result[0]?.root.id).toBe("root")
expect(result[0]?.sessions.map((item) => item.id)).toEqual(["root", "child"])
})

test("does not select a root when a descendant is recent", () => {
const root = session("root", 10)
const child = session("child", 200, "root")
expect(selectPruneCandidates([root, child], 100, new Map())).toEqual([])
})

test("does not select an active family", () => {
const root = session("root", 10)
const child = session("child", 20, "root")
const active = new Map<string, PruneStatus>([[child.id, { type: "busy" }]])

expect(selectPruneCandidates([root, child], 100, active)).toEqual([])
})

test("skips shared sessions", () => {
const root = session("root", 10, undefined, { share: { url: "https://example.test/share" } })
expect(selectPruneCandidates([root], 100, new Map())).toEqual([])
})
})
Loading