mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 12:48:27 +00:00
fix(desktop): clean stale draft stores (#34651)
This commit is contained in:
parent
6f2ff35656
commit
2f4d36d64e
5 changed files with 216 additions and 1 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
98
packages/desktop/src/main/store-cleanup.test.ts
Normal file
98
packages/desktop/src/main/store-cleanup.test.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
96
packages/desktop/src/main/store-cleanup.ts
Normal file
96
packages/desktop/src/main/store-cleanup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue