* 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>
207 lines
8.1 KiB
TypeScript
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)
|
|
})
|
|
})
|