Skip to content

Commit eff2fd6

Browse files
committed
feat(core): add storage.sessions config to delete old sessions
1 parent 863ea41 commit eff2fd6

12 files changed

Lines changed: 302 additions & 0 deletions

File tree

‎packages/client/src/promise/generated/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,6 +2299,7 @@ export type ConfigEntry =
22992299
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
23002300
worktree?: ConfigWorktree
23012301
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
2302+
storage?: { sessions?: string }
23022303
providers?: {
23032304
[x: string]: {
23042305
canonical?: ProviderID

‎packages/core/src/config/normalize.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export function normalize(input: unknown): Result {
216216
websearch: Info.fields.websearch,
217217
worktree: Info.fields.worktree,
218218
warming: Info.fields.warming,
219+
storage: Info.fields.storage,
219220
}
220221
Object.entries(nativeAtomic).forEach(([key, schema]) => {
221222
if (!own(input, key)) return
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
export * as SessionExpiry from "./expiry.js"
2+
3+
import path from "path"
4+
import { and, asc, isNull, lt } from "drizzle-orm"
5+
import { parse, type ParseError } from "jsonc-parser"
6+
import { Clock, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
7+
import { FSUtil } from "@opencode/util/fs-util"
8+
import { Global } from "@opencode/util/global"
9+
import { makeGlobalNode } from "@opencode/util/effect/app-node"
10+
import { Database } from "../database/database.js"
11+
import { Job } from "../job.js"
12+
import { Session } from "../session.js"
13+
import { SessionExecution } from "./execution.js"
14+
import { SessionTable } from "./sql.js"
15+
16+
const SWEEP_INTERVAL = Duration.hours(1)
17+
18+
const decodeSessions = Schema.decodeUnknownOption(Schema.DurationFromString)
19+
20+
/** Projects `storage.sessions` from one global config file, like the CLI updater's `update` policy. */
21+
export function decodeMaxAge(text: string) {
22+
const errors: ParseError[] = []
23+
const input: unknown = parse(text, errors, { allowTrailingComma: true })
24+
if (errors.length || typeof input !== "object" || input === null || !("storage" in input)) return
25+
const storage = input.storage
26+
if (typeof storage !== "object" || storage === null || !("sessions" in storage)) return
27+
return Option.getOrUndefined(decodeSessions(storage.sessions))
28+
}
29+
30+
/**
31+
* Reads `storage.sessions` from the global config directory only. Project config is never read,
32+
* so a project cannot delete another project's Sessions. Later files win, as with `update`.
33+
*/
34+
export const configured = Effect.fn("SessionExpiry.configured")(function* () {
35+
const global = yield* Global.Service
36+
const fs = yield* FSUtil.Service
37+
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
38+
fs.readFileStringSafe(path.join(global.config, name)).pipe(
39+
Effect.map((text) => (text === undefined ? undefined : decodeMaxAge(text))),
40+
Effect.orElseSucceed(() => undefined),
41+
),
42+
)
43+
return values.findLast((value) => value !== undefined)
44+
})
45+
46+
/**
47+
* Deletes top-level Sessions, with their children, whose last activity is older than `maxAge`.
48+
* Returns the deleted Session count.
49+
*/
50+
export const expire = Effect.fn("SessionExpiry.expire")(function* (maxAge: Duration.Duration) {
51+
const { db } = yield* Database.Service
52+
const execution = yield* SessionExecution.Service
53+
const jobs = yield* Job.Service
54+
const sessions = yield* Session.Service
55+
const cutoff = (yield* Clock.currentTimeMillis) - Duration.toMillis(maxAge)
56+
// Suspended Sessions belong to restart recovery, which resumes or terminalizes them.
57+
const expired = yield* db
58+
.select({ id: SessionTable.id })
59+
.from(SessionTable)
60+
.where(
61+
and(isNull(SessionTable.parent_id), isNull(SessionTable.time_suspended), lt(SessionTable.time_updated, cutoff)),
62+
)
63+
.orderBy(asc(SessionTable.time_updated))
64+
.all()
65+
.pipe(Effect.orDie)
66+
if (expired.length === 0) return 0
67+
// Waiting on a question, a long tool call, or a background job emits no activity.
68+
const occupied = new Set([
69+
...(yield* execution.active),
70+
...(yield* jobs.runningBackground).flatMap((recovery) =>
71+
recovery.kind === "shell" ? [recovery.sessionID] : [recovery.childSessionID, recovery.parentSessionID],
72+
),
73+
])
74+
const removable = expired.filter((row) => !occupied.has(row.id))
75+
yield* Effect.forEach(
76+
removable,
77+
(row) => sessions.remove(row.id).pipe(Effect.catchTag("Session.NotFoundError", () => Effect.void)),
78+
{ discard: true },
79+
)
80+
return removable.length
81+
})
82+
83+
/** Applies the global `storage.sessions` config. Unset keeps every Session. */
84+
export const sweep = Effect.fn("SessionExpiry.sweep")(function* () {
85+
const maxAge = yield* configured()
86+
if (maxAge === undefined) return
87+
const millis = Duration.toMillis(maxAge)
88+
if (!Number.isFinite(millis) || millis <= 0) {
89+
yield* Effect.logWarning("storage.sessions must be a finite positive duration")
90+
return
91+
}
92+
const count = yield* expire(maxAge)
93+
if (count === 0) return
94+
yield* Effect.logInfo("deleted expired sessions", { count, maxAge: Duration.format(maxAge) })
95+
})
96+
97+
export const node = makeGlobalNode({
98+
name: "session-expiry-sweep",
99+
layer: Layer.effectDiscard(
100+
sweep().pipe(
101+
Effect.ignoreCause({ log: "Error", message: "Session expiry sweep failed" }),
102+
Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)),
103+
Effect.forkScoped,
104+
),
105+
),
106+
deps: [Database.node, FSUtil.node, Global.node, Job.node, Session.node, SessionExecution.node],
107+
})

‎packages/core/test/config/normalization.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ describe("ConfigNormalize", () => {
116116
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
117117
})
118118

119+
test("keeps storage durations", () => {
120+
const result = normalized({ storage: { sessions: "30 days" } })
121+
expect(result.encoded.storage).toEqual({ sessions: "2592000000 millis" })
122+
const info = Schema.decodeUnknownSync(Info)(result.encoded)
123+
expect(Duration.toMillis(info.storage?.sessions ?? Duration.zero)).toBe(2_592_000_000)
124+
})
125+
119126
test("preserves arbitrary JSON-round-tripped native configuration", () => {
120127
const result = Effect.runSync(
121128
Arbitrary.checkEffect(
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import fs from "fs"
2+
import os from "os"
3+
import path from "path"
4+
import { afterAll, describe, expect } from "bun:test"
5+
import { Clock, Duration, Effect } from "effect"
6+
import { eq } from "drizzle-orm"
7+
import { Bus } from "@opencode/core/bus"
8+
import { Database } from "@opencode/core/database/database"
9+
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
10+
import { Job } from "@opencode/core/job"
11+
import { Location } from "@opencode/core/location"
12+
import { Project } from "@opencode/core/project"
13+
import { AbsolutePath } from "@opencode/core/schema"
14+
import { Session } from "@opencode/core/session"
15+
import { SessionExecution } from "@opencode/core/session/execution"
16+
import { SessionProjector } from "@opencode/core/session/projector"
17+
import { SessionExpiry } from "@opencode/core/session/expiry"
18+
import { SessionTable } from "@opencode/core/session/sql"
19+
import { SessionStore } from "@opencode/core/session/store"
20+
import { Global } from "@opencode/util/global"
21+
import { FSUtil } from "@opencode/util/fs-util"
22+
import { LayerNode } from "@opencode/util/effect/layer-node"
23+
import { testEffect } from "./lib/effect"
24+
import { globalProjectNode } from "./lib/project"
25+
26+
const globalDirectory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "opencode-session-expiry-")))
27+
afterAll(() => fs.rmSync(globalDirectory, { recursive: true, force: true }))
28+
29+
const it = testEffect(
30+
AppNodeBuilder.build(
31+
LayerNode.group([
32+
Database.node,
33+
Bus.node,
34+
SessionProjector.node,
35+
SessionStore.node,
36+
Session.node,
37+
SessionExecution.node,
38+
Job.node,
39+
Global.node,
40+
FSUtil.node,
41+
]),
42+
[
43+
Project.node.replace(globalProjectNode),
44+
SessionExecution.node.replace(SessionExecution.noopLayer),
45+
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
46+
],
47+
),
48+
)
49+
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
50+
const day = Duration.toMillis(Duration.days(1))
51+
52+
const age = Effect.fn(function* (sessionID: Session.ID, days: number) {
53+
const { db } = yield* Database.Service
54+
const now = yield* Clock.currentTimeMillis
55+
yield* db
56+
.update(SessionTable)
57+
.set({ time_updated: now - days * day })
58+
.where(eq(SessionTable.id, sessionID))
59+
.run()
60+
})
61+
62+
const exists = (sessionID: Session.ID) =>
63+
Effect.gen(function* () {
64+
const session = yield* Session.Service
65+
return yield* session.get(sessionID).pipe(
66+
Effect.as(true),
67+
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(false)),
68+
)
69+
})
70+
71+
describe("SessionExpiry", () => {
72+
it.effect("deletes top-level sessions and their children past their max age", () =>
73+
Effect.gen(function* () {
74+
const session = yield* Session.Service
75+
const old = yield* session.create({ location })
76+
const child = yield* session.create({ parentID: old.id })
77+
const recent = yield* session.create({ location })
78+
yield* age(old.id, 40)
79+
// A child's own age never matters; it lives and dies with its top-level Session.
80+
yield* age(child.id, 1)
81+
yield* age(recent.id, 10)
82+
83+
expect(yield* SessionExpiry.expire(Duration.days(30))).toBe(1)
84+
expect(yield* exists(old.id)).toBe(false)
85+
expect(yield* exists(child.id)).toBe(false)
86+
expect(yield* exists(recent.id)).toBe(true)
87+
}),
88+
)
89+
90+
it.effect("keeps suspended sessions for restart recovery", () =>
91+
Effect.gen(function* () {
92+
const session = yield* Session.Service
93+
const store = yield* SessionStore.Service
94+
const suspended = yield* session.create({ location })
95+
yield* store.claim(suspended.id)
96+
yield* age(suspended.id, 40)
97+
98+
expect(yield* SessionExpiry.expire(Duration.days(30))).toBe(0)
99+
expect(yield* exists(suspended.id)).toBe(true)
100+
}),
101+
)
102+
103+
it.effect("reads storage.sessions from global config and keeps sessions when unset", () =>
104+
Effect.gen(function* () {
105+
const file = path.join(globalDirectory, "opencode.json")
106+
fs.rmSync(file, { force: true })
107+
expect(yield* SessionExpiry.configured()).toBeUndefined()
108+
109+
fs.writeFileSync(file, JSON.stringify({ storage: { sessions: "30 days" } }))
110+
expect(Duration.toMillis((yield* SessionExpiry.configured())!)).toBe(Duration.toMillis(Duration.days(30)))
111+
fs.rmSync(file)
112+
}),
113+
)
114+
115+
it.effect("decodes storage.sessions from JSONC and ignores invalid values", () =>
116+
Effect.sync(() => {
117+
expect(Duration.toMillis(SessionExpiry.decodeMaxAge('{ "storage": { "sessions": "7 days", }, }')!)).toBe(7 * day)
118+
expect(SessionExpiry.decodeMaxAge('{ "storage": {} }')).toBeUndefined()
119+
expect(SessionExpiry.decodeMaxAge('{ "storage": { "sessions": "soon" } }')).toBeUndefined()
120+
expect(SessionExpiry.decodeMaxAge("{ broken")).toBeUndefined()
121+
}),
122+
)
123+
})

‎packages/protocol/openapi.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14448,6 +14448,15 @@
1444814448
}
1444914449
]
1445014450
},
14451+
"storage": {
14452+
"type": "object",
14453+
"properties": {
14454+
"sessions": {
14455+
"type": "string"
14456+
}
14457+
},
14458+
"additionalProperties": false
14459+
},
1445114460
"providers": {
1445214461
"type": "object",
1445314462
"additionalProperties": {

‎packages/schema/src/config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ConfigPlugin } from "./config/plugin.js"
1717
import { ConfigPolicy } from "./config/policy.js"
1818
import { ConfigProvider } from "./config/provider.js"
1919
import { ConfigReference } from "./config/reference.js"
20+
import { ConfigStorage } from "./config/storage.js"
2021
import { ConfigWebSearch } from "./config/websearch.js"
2122
import { ConfigToolOutput } from "./config/tool-output.js"
2223
import { ConfigWatcher } from "./config/watcher.js"
@@ -109,6 +110,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
109110
warming: ConfigWarming.Warming.pipe(optional).annotate({
110111
description: "Keep recently active sessions warm with transient model requests (default: false)",
111112
}),
113+
storage: ConfigStorage.Info.pipe(optional).annotate({
114+
description: "Automatic cleanup of locally stored data",
115+
}),
112116
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
113117
experimental: ConfigExperimental.Info.pipe(optional),
114118
}) {}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export * as ConfigStorage from "./storage.js"
2+
3+
import { Schema } from "effect"
4+
import { optional } from "../schema.js"
5+
6+
export class Info extends Schema.Class<Info>("Config.Storage")({
7+
sessions: Schema.DurationFromString.pipe(optional).annotate({
8+
description:
9+
'Delete sessions whose last activity is older than this duration, such as "30 days". Read from global config only. Unset keeps sessions forever',
10+
}),
11+
}) {}

‎packages/server/src/routes.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { LocationServiceMap } from "@opencode/core/location-service-map"
2727
import { LocationActivity } from "@opencode/core/location-activity"
2828
import { ModelsDev } from "@opencode/core/models-dev"
2929
import { SessionRestart } from "@opencode/core/session/execution/restart"
30+
import { SessionExpiry } from "@opencode/core/session/expiry"
3031
import { PluginUpdate } from "@opencode/core/plugin/update"
3132
import { SdkPlugins } from "@opencode/core/plugin/sdk"
3233
import { WellKnown } from "@opencode/core/wellknown"
@@ -74,6 +75,7 @@ const applicationServiceNodes = [
7475
LocationServiceMap.node,
7576
LocationActivity.node,
7677
SessionRestart.node,
78+
SessionExpiry.node,
7779
Workspace.node,
7880
] as const
7981
const applicationServices = LayerNode.group(applicationServiceNodes)

‎services/www/openapi.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14448,6 +14448,15 @@
1444814448
}
1444914449
]
1445014450
},
14451+
"storage": {
14452+
"type": "object",
14453+
"properties": {
14454+
"sessions": {
14455+
"type": "string"
14456+
}
14457+
},
14458+
"additionalProperties": false
14459+
},
1445114460
"providers": {
1445214461
"type": "object",
1445314462
"additionalProperties": {

0 commit comments

Comments
 (0)