fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013)

This commit is contained in:
Luke Parker 2026-04-30 08:39:19 +10:00 committed by GitHub
commit d7b7be1909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 485 additions and 202 deletions

View file

@ -0,0 +1,24 @@
export type PathKey = string & { _brand: "PathKey" }
const isDrive = (value: string) => {
if (value.length !== 2) return false
const code = value.charCodeAt(0)
return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122))
}
const trimTrailingSlashes = (value: string) => {
for (let i = value.length - 1; i >= 0; i--) {
if (value[i] !== "/") return value.slice(0, i + 1)
}
return ""
}
const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\")
export const pathKey = (path: string) => {
const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path
const trimmed = trimTrailingSlashes(value)
if (!trimmed && value.startsWith("/")) return "/" as PathKey
if (isDrive(trimmed)) return `${trimmed}/` as PathKey
return trimmed as PathKey
}

View file

@ -1,6 +1,8 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
type PersistTestingType = typeof import("./persist").PersistTesting
type PersistType = typeof import("./persist").Persist
type RemovePersistedType = typeof import("./persist").removePersisted
class MemoryStorage implements Storage {
private values = new Map<string, string>()
@ -45,6 +47,8 @@ class MemoryStorage implements Storage {
const storage = new MemoryStorage()
let persistTesting: PersistTestingType
let Persist: PersistType
let removePersisted: RemovePersistedType
beforeAll(async () => {
mock.module("@/context/platform", () => ({
@ -53,6 +57,8 @@ beforeAll(async () => {
const mod = await import("./persist")
persistTesting = mod.PersistTesting
Persist = mod.Persist
removePersisted = mod.removePersisted
})
beforeEach(() => {
@ -112,4 +118,50 @@ describe("persist localStorage resilience", () => {
expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse()
})
test("workspace target keeps raw path storage as legacy fallback", () => {
const target = Persist.workspace("C:\\Users\\foo", "vcs")
expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
})
test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => {
const target = Persist.workspace("C:/Users/foo", "vcs")
expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo"))
expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")])
})
test("migrates direct legacy keys into scoped storage", () => {
storage.setItem("legacy.workspace", '{"value":2}')
const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"])
const current = persistTesting.localStorageWithPrefix(target.storage!)
const legacyStore = persistTesting.localStorageDirect()
const result = persistTesting.migrateLegacy({
current,
legacyStore,
stores: [],
keys: target.legacy!,
key: target.key,
defaults: { value: 1 },
})
expect(result).toBe('{"value":2}')
expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}')
expect(legacyStore.getItem("legacy.workspace")).toBeNull()
expect(storage.getItem("legacy.workspace")).toBeNull()
})
test("removes legacy workspace storage when removing persisted target", () => {
const target = Persist.workspace("C:\\Users\\foo", "terminal")
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}')
removePersisted(target)
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
})
})

View file

@ -3,6 +3,7 @@ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primi
import { checksum } from "@opencode-ai/core/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/utils/path-key"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [
@ -14,6 +15,7 @@ type PersistedWithReady<T> = [
type PersistTarget = {
storage?: string
legacyStorageNames?: string[]
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
@ -208,12 +210,153 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
return JSON.stringify(merged)
}
function readCurrent(input: {
storage: SyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
const raw = input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
input.storage.removeItem(input.key)
return null
}
if (raw !== next) input.storage.setItem(input.key, next)
return next
}
function migrateLegacy(input: {
current: SyncStorage
legacyStore?: SyncStorage
stores: SyncStorage[]
keys: string[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
for (const store of input.stores) {
const raw = store.getItem(input.key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
store.removeItem(input.key)
continue
}
input.current.setItem(input.key, next)
store.removeItem(input.key)
return next
}
if (!input.legacyStore) return null
for (const key of input.keys) {
const raw = input.legacyStore.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
input.legacyStore.removeItem(key)
continue
}
input.current.setItem(input.key, next)
input.legacyStore.removeItem(key)
return next
}
return null
}
async function readCurrentAsync(input: {
storage: AsyncStorage
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
const raw = await input.storage.getItem(input.key)
if (raw === null) return
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
await input.storage.removeItem(input.key).catch(() => undefined)
return null
}
if (raw !== next) await input.storage.setItem(input.key, next)
return next
}
async function removeAsync(storage: AsyncStorage, key: string) {
try {
await storage.removeItem(key)
} catch {}
}
async function migrateLegacyAsync(input: {
current: AsyncStorage
legacyStore?: AsyncStorage
stores: AsyncStorage[]
keys: string[]
key: string
defaults: unknown
migrate?: (value: unknown) => unknown
}) {
for (const store of input.stores) {
const raw = await store.getItem(input.key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
await removeAsync(store, input.key)
continue
}
await input.current.setItem(input.key, next)
await store.removeItem(input.key)
return next
}
if (!input.legacyStore) return null
for (const key of input.keys) {
const raw = await input.legacyStore.getItem(key)
if (raw === null) continue
const next = normalize(input.defaults, raw, input.migrate)
if (next === undefined) {
await removeAsync(input.legacyStore, key)
continue
}
await input.current.setItem(input.key, next)
await input.legacyStore.removeItem(key)
return next
}
return null
}
function workspaceStorage(dir: string) {
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
const raw = workspaceStorage(dir)
if (raw !== storage) result.add(raw)
const key = pathKey(dir)
const drive = key.length >= 3 && key[1] === ":" && key[2] === "/"
if (drive) {
const backslash = workspaceStorage(key.replaceAll("/", "\\"))
if (backslash !== storage) result.add(backslash)
}
if (result.size === 0) return
return [...result]
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
const scope = `prefix:${prefix}`
@ -304,6 +447,7 @@ function localStorageDirect(): SyncStorage {
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
migrateLegacy,
normalize,
workspaceStorage,
}
@ -313,10 +457,17 @@ export const Persist = {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
const storage = workspaceStorage(pathKey(dir))
return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
const storage = workspaceStorage(pathKey(dir))
return {
storage,
legacyStorageNames: legacyWorkspaceStorage(dir),
key: `session:${session}:${key}`,
legacy,
}
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
@ -324,11 +475,15 @@ export const Persist = {
},
}
export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) {
export function removePersisted(target: { storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform) {
const isDesktop = platform?.platform === "desktop" && !!platform.storage
if (isDesktop) {
return platform.storage?.(target.storage)?.removeItem(target.key)
void platform.storage?.(target.storage)?.removeItem(target.key)
for (const storage of target.legacyStorageNames ?? []) {
void platform.storage?.(storage)?.removeItem(target.key)
}
return
}
if (!target.storage) {
@ -337,6 +492,9 @@ export function removePersisted(target: { storage?: string; key: string }, platf
}
localStorageWithPrefix(target.storage).removeItem(target.key)
for (const storage of target.legacyStorageNames ?? []) {
localStorageWithPrefix(storage).removeItem(target.key)
}
}
export function persisted<T>(
@ -363,39 +521,27 @@ export function persisted<T>(
return platform.storage?.(LEGACY_STORAGE)
})()
const legacyStorageNames = config.legacyStorageNames ?? []
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
current.removeItem(key)
return null
}
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
legacyStore.removeItem(legacyKey)
continue
}
current.setItem(key, next)
legacyStore.removeItem(legacyKey)
return next
}
return null
const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
if (value !== undefined) return value
return migrateLegacy({
current,
legacyStore,
stores: legacyStores,
keys: legacy,
key,
defaults,
migrate: config.migrate,
})
},
setItem: (key, value) => {
current.setItem(key, value)
@ -410,37 +556,21 @@ export function persisted<T>(
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const legacyStores = legacyStorageNames.map((name) => platform.storage?.(name) as AsyncStorage | undefined).filter((x) => !!x)
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
await current.removeItem(key).catch(() => undefined)
return null
}
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
await legacyStore.removeItem(legacyKey).catch(() => undefined)
continue
}
await current.setItem(key, next)
await legacyStore.removeItem(legacyKey)
return next
}
return null
const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
if (value !== undefined) return value
return migrateLegacyAsync({
current,
legacyStore,
stores: legacyStores,
keys: legacy,
key,
defaults,
migrate: config.migrate,
})
},
setItem: async (key, value) => {
await current.setItem(key, value)