Kaynağa Gözat

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

Brendan Allan 1 ay önce
ebeveyn
işleme
2f4d36d64e

+ 14 - 0
packages/desktop/src/main/index.ts

@@ -40,6 +40,7 @@ import { createWslServersController } from "./wsl/servers"
 import { registerWslIpcHandlers } from "./wsl/ipc"
 import { registerWslIpcHandlers } from "./wsl/ipc"
 import { spawnWslSidecar } from "./wsl/sidecar"
 import { spawnWslSidecar } from "./wsl/sidecar"
 import { migrate } from "./migrate"
 import { migrate } from "./migrate"
+import { cleanupStoreFiles } from "./store-cleanup"
 
 
 const APP_NAMES: Record<string, string> = {
 const APP_NAMES: Record<string, string> = {
   dev: "OpenCode Dev",
   dev: "OpenCode Dev",
@@ -242,6 +243,19 @@ const main = Effect.gen(function* () {
   yield* Effect.promise(() => app.whenReady())
   yield* Effect.promise(() => app.whenReady())
 
 
   if (!TEST_ONBOARDING) migrate()
   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")
   app.setAsDefaultProtocolClient("opencode")
   registerRendererProtocol()
   registerRendererProtocol()
   setDockIcon()
   setDockIcon()

+ 3 - 1
packages/desktop/src/main/ipc.ts

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

+ 98 - 0
packages/desktop/src/main/store-cleanup.test.ts

@@ -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 - 0
packages/desktop/src/main/store-cleanup.ts

@@ -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
+  }
+}

+ 5 - 0
packages/desktop/src/main/store.ts

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