Files
openchamber/packages/ui/src/sync/persist-cache.test.ts
T
Steffen MächtelandBohdan Triapitsyn 3df97908fe feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR (#3135)
* feat(chats): relocate managed chat worktrees via OPENCHAMBER_CHATS_DIR

Projectless-chat worktrees were hard-pinned to
<home>/.config/openchamber/chats: the UI joined the path client-side,
workspace checks allowed only the config root, and identification matched
the literal path segment. When the OpenCode server runs as a separate
user (UID-separated setups), that root is unreachable — every chat
session answered HTTP 500 (EACCES on the session directory).

The server now owns the chats root. OPENCHAMBER_CHATS_DIR relocates it
(default unchanged: <config root>/chats); /api/fs/home answers
{ home, chatsRoot }; fs workspace checks accept the managed chats root
next to the config root; the client resolves the root from the server
(per-runtime cached, warmed at bootstrap so sync classification sees it)
and falls back to the home join for older servers.

Refs #3130

* chore: trim added comments to local precedent

* fix: forward managedChatsRoot through feature-routes-runtime to registerFsRoutes

* fix(chats): await the root warm-up and keep the legacy chats root owned

Review feedback on #3135:

- bootstrapGlobal now awaits warmChatsRootDirectory, so synchronous
  session classification never sees an empty root cache (relocated
  sessions were grouped as project sessions when the session list
  outran /api/fs/home).
- managedProjectRoots keeps the legacy <config root>/chats entry next to
  OPENCHAMBER_CHATS_DIR, so memory ownership of existing chats survives
  relocation.

* fix(chats): distinguish chats-root fetch failure from older servers

* fix(sync): rehydrate managed chat sessions after the chats root warms

* fix(fs): pass managed roots through the symlink and git-dirs path checks after the main merge

* docs: drop changelog edits; changelog is the maintainer's release-time work

* fix(chats): keep legacy chat directories deletable while the root is relocated

* fix(chats): resolve roots before cleanup and initial session loads

* test(chats): type runtime spies against actual SDK contracts

---------

Signed-off-by: Steffen Mächtel <info@steffen-maechtel.de>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-09-05 19:24:52 +03:00

207 lines
8.1 KiB
TypeScript

import { opencodeClient } from '@/lib/opencode/client';
import { ensureChatsRootDirectory } from '@/lib/chatDirectories';
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { switchRuntimeEndpoint } from "@/lib/runtime-switch"
import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache"
import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics"
class TestStorage implements Storage {
readonly values = new Map<string, string>()
maxValueLength = Number.POSITIVE_INFINITY
writes = 0
get length(): number {
return this.values.size
}
clear(): void {
this.values.clear()
}
getItem(key: string): string | null {
return this.values.get(key) ?? null
}
key(index: number): string | null {
return [...this.values.keys()][index] ?? null
}
removeItem(key: string): void {
this.values.delete(key)
}
setItem(key: string, value: string): void {
if (value.length > this.maxValueLength) throw new DOMException("Quota exceeded", "QuotaExceededError")
this.writes += 1
this.values.set(key, value)
}
}
const originalLocalStorage = globalThis.localStorage
const directory = "/repo"
let storage: TestStorage
const waitForPersistence = () => new Promise((resolve) => setTimeout(resolve, 70))
const hashCode = (value: string): string => {
let hash = 0
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index)
hash |= 0
}
return Math.abs(hash).toString(36)
}
const legacySessionKey = (value: string): string => {
const head = value.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "_")
return `oc.dir.${head}.${hashCode(value)}.sessions`
}
const session = (
index: number,
updated: number,
title = `Session ${index}`,
sessionDirectory = directory,
): Session => ({
id: `ses_${String(index).padStart(3, "0")}`,
projectID: "project",
directory: sessionDirectory,
title,
version: "1",
time: { created: updated - 1, updated },
} as Session)
beforeEach(async () => {
storage = new TestStorage()
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage })
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-default.test", runtimeKey: "runtime-default" })
const originalHomeInfo = opencodeClient.getFilesystemHomeInfo
opencodeClient.getFilesystemHomeInfo = async () => ({ home: '/home/user' })
await ensureChatsRootDirectory()
opencodeClient.getFilesystemHomeInfo = originalHomeInfo
})
afterEach(() => {
setSyncPerformanceDiagnosticsEnabled(false)
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: originalLocalStorage })
})
describe("persisted directory sessions", () => {
test("keeps one runtime-scoped startup snapshot for managed chats", async () => {
const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a")
persistManagedChatSessions([session(2, 3), chat])
await waitForPersistence()
expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id])
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" })
expect(readManagedChatSessions()).toEqual([])
})
test("coalesces a continuing burst into one trailing session write", async () => {
persistSessions(directory, [session(1, 1)])
await new Promise((resolve) => setTimeout(resolve, 30))
persistSessions(directory, [session(1, 2)])
await waitForPersistence()
expect(storage.writes).toBe(1)
expect(readDirCache(directory).sessions?.[0]?.time.updated).toBe(2)
})
test("keeps the 50 most recently updated sessions across restart reads", async () => {
const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated))
persistSessions(directory, sessions)
await waitForPersistence()
const cached = readDirCache(directory).sessions ?? []
const cachedIds = new Set(cached.map((item) => item.id))
const expectedIds = new Set(Array.from({ length: 50 }, (_, index) => session(index, index).id))
expect(cached).toHaveLength(50)
expect(cachedIds).toEqual(expectedIds)
})
test("persists authoritative empty instead of resurrecting legacy sessions", () => {
const legacyKey = legacySessionKey(directory)
storage.setItem(legacyKey, JSON.stringify([session(1, 1)]))
persistSessions(directory, [])
expect(readDirCache(directory).sessions).toEqual([])
expect(storage.getItem(legacyKey)).toBeNull()
})
test("replaces stale data with a smaller recent snapshot when quota is tight", async () => {
persistSessions(directory, [session(1, 1, "old")])
await waitForPersistence()
storage.maxValueLength = 700
const sessions = Array.from({ length: 50 }, (_, index) => session(index + 10, index + 10, "x".repeat(80)))
persistSessions(directory, sessions)
await waitForPersistence()
const cached = readDirCache(directory).sessions ?? []
expect(cached.length).toBeGreaterThan(0)
expect(cached.length).toBeLessThan(50)
expect(cached.some((item) => item.title === "old")).toBe(false)
expect(cached.map((item) => item.id)).toEqual(sessions.slice(-cached.length).map((item) => item.id))
})
test("isolates snapshots by runtime and directory", async () => {
const otherDirectory = "/other-repo"
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-a.test", runtimeKey: "runtime-a" })
persistSessions(directory, [session(1, 1, "runtime A")])
persistSessions(otherDirectory, [session(2, 2, "other directory", otherDirectory)])
await waitForPersistence()
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-b.test", runtimeKey: "runtime-b" })
persistSessions(directory, [session(3, 3, "runtime B")])
await waitForPersistence()
expect(readDirCache(directory).sessions?.map((item) => item.title)).toEqual(["runtime B"])
expect(readDirCache(otherDirectory).sessions).toBe(undefined)
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-a.test", runtimeKey: "runtime-a" })
expect(readDirCache(directory).sessions?.map((item) => item.title)).toEqual(["runtime A"])
expect(readDirCache(otherDirectory).sessions?.map((item) => item.title)).toEqual(["other directory"])
})
test("coalesces burst updates per runtime and directory while serving the latest pending value", async () => {
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-coalesce.test", runtimeKey: "runtime-coalesce" })
const writesBefore = storage.writes
setSyncPerformanceDiagnosticsEnabled(true)
for (let index = 0; index < 100; index += 1) {
persistSessions(directory, [session(index, index)])
}
expect(readDirCache(directory).sessions?.[0]?.id).toBe(session(99, 99).id)
expect(storage.writes).toBe(writesBefore)
await waitForPersistence()
expect(storage.writes - writesBefore).toBe(1)
expect(readDirCache(directory).sessions?.[0]?.id).toBe(session(99, 99).id)
expect(getSyncPerformanceDiagnostics()?.persistenceSerializations).toBe(1)
expect(getSyncPerformanceDiagnostics()?.persistenceStorageWrites).toBe(1)
})
test("writes authoritative empty immediately and prevents an older pending snapshot from returning", async () => {
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-empty.test", runtimeKey: "runtime-empty" })
persistSessions(directory, [session(1, 1)])
persistSessions(directory, [])
expect(readDirCache(directory).sessions).toEqual([])
await waitForPersistence()
expect(readDirCache(directory).sessions).toEqual([])
})
test("does not commit a pending snapshot after its runtime is no longer active", async () => {
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-stale-a.test", runtimeKey: "runtime-stale-a" })
persistSessions(directory, [session(1, 1, "runtime stale A")])
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-stale-b.test", runtimeKey: "runtime-stale-b" })
await waitForPersistence()
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-stale-a.test", runtimeKey: "runtime-stale-a" })
expect(readDirCache(directory).sessions).toBe(undefined)
})
})