fix(desktop): clean stale draft stores (#34651)

This commit is contained in:
Brendan Allan 2026-07-01 17:03:27 +08:00 committed by GitHub
parent 6f2ff35656
commit 2f4d36d64e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 1 deletions

View file

@ -40,6 +40,7 @@ import { createWslServersController } from "./wsl/servers"
import { registerWslIpcHandlers } from "./wsl/ipc"
import { spawnWslSidecar } from "./wsl/sidecar"
import { migrate } from "./migrate"
import { cleanupStoreFiles } from "./store-cleanup"
const APP_NAMES: Record<string, string> = {
dev: "OpenCode Dev",
@ -242,6 +243,19 @@ const main = Effect.gen(function* () {
yield* Effect.promise(() => app.whenReady())
if (!TEST_ONBOARDING) migrate()
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result.deleted.length === 0) return
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
}),
),
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to clean scoped store files", error)
}),
),
)
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()

View file

@ -8,7 +8,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore } from "./store"
import { getStore, removeStoreFileIfEmpty } from "./store"
import { getPinchZoomEnabled, getWindowID, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
import { createUpdaterSubscriptions } from "./updater-subscriptions"
@ -91,9 +91,11 @@ export function registerIpcHandlers(deps: Deps) {
})
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
getStore(name).delete(key)
void removeStoreFileIfEmpty(name)
})
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
getStore(name).clear()
void removeStoreFileIfEmpty(name)
})
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
const store = getStore(name)

View file

@ -0,0 +1,98 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./store-cleanup"
const roots: string[] = []
async function tempRoot() {
const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-"))
roots.push(root)
return root
}
async function writeStore(root: string, name: string, value: string, modified: Date) {
await writeFile(join(root, name), value)
await utimes(join(root, name), modified, modified)
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe("store cleanup", () => {
test("removes empty scoped stores and leaves global stores alone", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await writeStore(root, "opencode.draft.empty.dat", "{}", now)
await writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
await writeStore(root, "opencode.global.dat", "{}", now)
await writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
const result = await cleanupStoreFiles(root, now.getTime())
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((await readdir(root)).sort()).toEqual(["opencode.global.dat", "opencode.workspace.empty.dat.json"])
})
test("removes stale drafts by age without removing non-empty workspace stores", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await writeStore(
root,
"opencode.draft.old.dat",
'{"draft:prompt":"hello"}',
new Date("2026-05-01T00:00:00.000Z"),
)
await writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
await writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
await writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = await cleanupStoreFiles(root, now.getTime())
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((await readdir(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
})
test("caps scoped stores by recency", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await Promise.all(
Array.from({ length: 102 }, (_, index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
),
)
const result = await cleanupStoreFiles(root, now.getTime())
const remaining = await readdir(root)
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
})
test("removes a scoped store immediately when it becomes empty", async () => {
const root = await tempRoot()
await writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
await writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
expect(await deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(await deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(await readdir(root)).toEqual(["opencode.global.dat"])
})
})

View file

@ -0,0 +1,96 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"
import { join } from "node:path"
const EMPTY_STORE_MAX_BYTES = 128
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const DRAFT_KEEP_RECENT = 100
type StoreKind = "draft" | "workspace"
type StoreCandidate = {
name: string
path: string
kind: StoreKind
modified: number
empty: boolean
}
export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) {
const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => [])
const candidates = (
await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const kind = storeKind(entry.name)
if (!kind) return
const file = join(userDataPath, entry.name)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) return
return {
name: entry.name,
path: file,
kind,
modified: stats.mtimeMs,
empty: await isEmptyStore(file, stats.size),
}
}),
)
).filter((candidate) => !!candidate)
const stale = new Set<StoreCandidate>()
for (const candidate of candidates) {
if (candidate.empty) stale.add(candidate)
if (candidate.kind === "draft" && now - candidate.modified > DRAFT_RETENTION_MS) stale.add(candidate)
}
candidates
.filter((candidate) => candidate.kind === "draft" && !candidate.empty)
.sort((a, b) => b.modified - a.modified)
.slice(DRAFT_KEEP_RECENT)
.forEach((candidate) => stale.add(candidate))
const deleted = await Promise.all(
[...stale].map(async (candidate) => {
await rm(candidate.path, { force: true })
return candidate.name
}),
)
return { scanned: candidates.length, deleted }
}
export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
if (!storeKind(name)) return false
const file = join(userDataPath, name)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) return false
if (!(await isEmptyStore(file, stats.size))) return false
await rm(file, { force: true })
return true
}
function storeKind(name: string): StoreKind | undefined {
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
}
async function isEmptyStore(file: string, size: number) {
if (size > EMPTY_STORE_MAX_BYTES) return false
const raw = await readFile(file, "utf8").catch(() => undefined)
if (raw === undefined) return false
if (raw.trim() === "") return true
try {
const parsed = JSON.parse(raw) as unknown
return (
typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && Object.keys(parsed).length === 0
)
} catch {
return false
}
}

View file

@ -2,6 +2,7 @@ import Store from "electron-store"
import electron from "electron"
import { SETTINGS_STORE } from "./store-keys"
import { deleteStoreFileIfEmpty } from "./store-cleanup"
const cache = new Map<string, Store>()
@ -21,3 +22,7 @@ export function getStore(name = SETTINGS_STORE) {
cache.set(name, next)
return next
}
export async function removeStoreFileIfEmpty(name: string) {
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
}