feat(app): improve desktop multi-server support (#30678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
7ae856a9e9
commit
7f33576f46
62 changed files with 1523 additions and 841 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { ServerScope } from "./server-scope"
|
||||
|
||||
type PersistTestingType = typeof import("./persist").PersistTesting
|
||||
type PersistType = typeof import("./persist").Persist
|
||||
|
|
@ -164,4 +165,29 @@ describe("persist localStorage resilience", () => {
|
|||
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
|
||||
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
|
||||
})
|
||||
|
||||
test("server workspace target preserves local storage and isolates remote storage", () => {
|
||||
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
|
||||
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
|
||||
const debian = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt")
|
||||
|
||||
expect(local).toEqual(Persist.workspace("/home/luke/repo", "prompt"))
|
||||
expect(windows.storage).not.toBe(local.storage)
|
||||
expect(debian.storage).not.toBe(local.storage)
|
||||
expect(debian.storage).not.toBe(windows.storage)
|
||||
expect(windows.legacyStorageNames).toBeUndefined()
|
||||
expect(debian.legacyStorageNames).toBeUndefined()
|
||||
})
|
||||
|
||||
test("server global target preserves local key and isolates remote keys", () => {
|
||||
expect(Persist.serverGlobal(ServerScope.local, "notification")).toEqual(Persist.global("notification"))
|
||||
expect(Persist.serverGlobal("https://debian.example" as ServerScope, "notification")).toEqual({
|
||||
storage: "opencode.global.dat",
|
||||
key: "https://debian.example\0notification",
|
||||
})
|
||||
})
|
||||
|
||||
test("server global target cannot collide when scope and key contain colons", () => {
|
||||
expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c"))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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"
|
||||
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
||||
|
||||
type InitType = Promise<string> | string | null
|
||||
type PersistedWithReady<T> = [
|
||||
|
|
@ -357,6 +358,11 @@ function legacyWorkspaceStorage(dir: string) {
|
|||
return [...result]
|
||||
}
|
||||
|
||||
function serverWorkspaceTarget(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||
if (scope !== ServerScope.local) return { storage: workspaceStorage(ScopedKey.from(scope, pathKey(dir))), key }
|
||||
return { storage: workspaceStorage(pathKey(dir)), legacyStorageNames: legacyWorkspaceStorage(dir), key, legacy }
|
||||
}
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const scope = `prefix:${prefix}`
|
||||
|
|
@ -456,23 +462,30 @@ export const Persist = {
|
|||
global(key: string, legacy?: string[]): PersistTarget {
|
||||
return { storage: GLOBAL_STORAGE, key, legacy }
|
||||
},
|
||||
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
|
||||
if (scope === ServerScope.local) return Persist.global(key, legacy)
|
||||
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
|
||||
},
|
||||
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||
const storage = workspaceStorage(pathKey(dir))
|
||||
return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy }
|
||||
return serverWorkspaceTarget(ServerScope.local, dir, `workspace:${key}`, legacy)
|
||||
},
|
||||
serverWorkspace(scope: ServerScopeValue, dir: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return serverWorkspaceTarget(scope, dir, `workspace:${key}`, legacy)
|
||||
},
|
||||
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
||||
const storage = workspaceStorage(pathKey(dir))
|
||||
return {
|
||||
storage,
|
||||
legacyStorageNames: legacyWorkspaceStorage(dir),
|
||||
key: `session:${session}:${key}`,
|
||||
legacy,
|
||||
}
|
||||
return serverWorkspaceTarget(ServerScope.local, dir, `session:${session}:${key}`, legacy)
|
||||
},
|
||||
serverSession(scope: ServerScopeValue, dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
|
||||
return serverWorkspaceTarget(scope, dir, `session:${session}:${key}`, legacy)
|
||||
},
|
||||
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
|
||||
if (session) return Persist.session(dir, session, key, legacy)
|
||||
return Persist.workspace(dir, key, legacy)
|
||||
},
|
||||
serverScoped(scope: ServerScopeValue, dir: string, session: string | undefined, key: string, legacy?: string[]) {
|
||||
if (session) return Persist.serverSession(scope, dir, session, key, legacy)
|
||||
return Persist.serverWorkspace(scope, dir, key, legacy)
|
||||
},
|
||||
}
|
||||
|
||||
export function removePersisted(
|
||||
|
|
|
|||
|
|
@ -25,6 +25,31 @@ describe("checkServerHealth", () => {
|
|||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||
})
|
||||
|
||||
test("allows slow servers thirty seconds by default", async () => {
|
||||
const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout")
|
||||
let timeoutMs = 0
|
||||
Object.defineProperty(AbortSignal, "timeout", {
|
||||
configurable: true,
|
||||
value: (ms: number) => {
|
||||
timeoutMs = ms
|
||||
return new AbortController().signal
|
||||
},
|
||||
})
|
||||
|
||||
const fetch = (async () =>
|
||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
|
||||
await checkServerHealth(server, fetch).finally(() => {
|
||||
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
|
||||
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
|
||||
})
|
||||
|
||||
expect(timeoutMs).toBe(30_000)
|
||||
})
|
||||
|
||||
test("returns unhealthy when request fails", async () => {
|
||||
const fetch = (async () => {
|
||||
throw new Error("network")
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ interface CheckServerHealthOptions {
|
|||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = 3000
|
||||
const defaultTimeoutMs = 30_000
|
||||
const defaultRetryCount = 2
|
||||
const defaultRetryDelayMs = 100
|
||||
const cacheMs = 750
|
||||
|
|
@ -132,7 +132,10 @@ export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabl
|
|||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
const key = ServerConnection.key(conn)
|
||||
const result = await checkServerHealth(conn.http)
|
||||
results[key] = result
|
||||
if (!dead) setStatus(key, result)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
|
|
|
|||
57
packages/app/src/utils/server-scope.test.ts
Normal file
57
packages/app/src/utils/server-scope.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ScopedKey, ServerScope, SessionRouteKey, SessionStateKey, migrateLegacySessionStateKeys } from "./server-scope"
|
||||
|
||||
describe("ServerScope", () => {
|
||||
test("uses a stable local scope for the canonical sidecar", () => {
|
||||
expect(String(ServerScope.fromServerKey("sidecar" as Parameters<typeof ServerScope.fromServerKey>[0]))).toBe("local")
|
||||
})
|
||||
|
||||
test("keeps configured loopback servers distinct from the canonical sidecar", () => {
|
||||
expect(String(ServerScope.fromServerKey("http://localhost:4096" as Parameters<typeof ServerScope.fromServerKey>[0]))).toBe(
|
||||
"http://localhost:4096",
|
||||
)
|
||||
})
|
||||
|
||||
test("uses a stable local scope for an explicit canonical web server", () => {
|
||||
const key = "http://localhost:4096" as Parameters<typeof ServerScope.fromServerKey>[0]
|
||||
expect(String(ServerScope.fromServerKey(key, key))).toBe("local")
|
||||
})
|
||||
})
|
||||
|
||||
describe("SessionStateKey", () => {
|
||||
test("combines local and remote scope with route identity", () => {
|
||||
const route = SessionRouteKey.fromRoute("cmVwbw", "session-1")
|
||||
expect(String(SessionStateKey.from(ServerScope.local, route))).toBe("local\0cmVwbw/session-1")
|
||||
expect(String(SessionStateKey.from("https://windows.example" as ServerScope, route))).toBe(
|
||||
"https://windows.example\0cmVwbw/session-1",
|
||||
)
|
||||
expect(SessionStateKey.from("https://debian.example" as ServerScope, route)).not.toBe(
|
||||
SessionStateKey.from("https://windows.example" as ServerScope, route),
|
||||
)
|
||||
})
|
||||
|
||||
test("extracts route keys from scoped and legacy state keys", () => {
|
||||
expect(String(SessionStateKey.route("cmVwbw/session-1"))).toBe("cmVwbw/session-1")
|
||||
expect(String(SessionStateKey.route("local\0cmVwbw/session-1"))).toBe("cmVwbw/session-1")
|
||||
expect(String(SessionStateKey.route("https://debian.example\0cmVwbw/session-1"))).toBe("cmVwbw/session-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateLegacySessionStateKeys", () => {
|
||||
test("copies legacy route keys into local scope without overwriting scoped state", () => {
|
||||
expect(
|
||||
migrateLegacySessionStateKeys({
|
||||
"cmVwbw/session-1": { active: "legacy" },
|
||||
"local\0cmVwbw/session-1": { active: "scoped" },
|
||||
"https://debian.example\0cmVwbw/session-1": { active: "remote" },
|
||||
}),
|
||||
).toEqual({
|
||||
"local\0cmVwbw/session-1": { active: "scoped" },
|
||||
"https://debian.example\0cmVwbw/session-1": { active: "remote" },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid identity fragments", () => {
|
||||
expect(() => ScopedKey.from(ServerScope.local, "bad\0directory")).toThrow("Scoped key part cannot contain null bytes")
|
||||
})
|
||||
})
|
||||
70
packages/app/src/utils/server-scope.ts
Normal file
70
packages/app/src/utils/server-scope.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { ServerConnection } from "@/context/server"
|
||||
|
||||
export type ServerScope = string & { readonly __brand: "ServerScope" }
|
||||
export type SessionRouteKey = string & { readonly __brand: "SessionRouteKey" }
|
||||
export type SessionStateKey = string & { readonly __brand: "SessionStateKey" }
|
||||
export type ScopedKey = string & { readonly __brand: "ScopedKey" }
|
||||
|
||||
const separator = "\u0000"
|
||||
|
||||
function fragment(label: string, value: string) {
|
||||
if (value.includes(separator)) throw new Error(`${label} cannot contain null bytes`)
|
||||
return value
|
||||
}
|
||||
|
||||
function compose(scope: ServerScope, parts: string[]) {
|
||||
return [fragment("Server scope", scope), ...parts.map((part) => fragment("Scoped key part", part))].join(separator)
|
||||
}
|
||||
|
||||
export const ServerScope = {
|
||||
local: "local" as ServerScope,
|
||||
fromServerKey(key: ServerConnection.Key, canonicalLocalServer?: ServerConnection.Key) {
|
||||
return fragment("Server scope", key === "sidecar" || key === canonicalLocalServer ? ServerScope.local : key) as ServerScope
|
||||
},
|
||||
}
|
||||
|
||||
export const SessionRouteKey = {
|
||||
fromRoute(dir: string | undefined, sessionID?: string) {
|
||||
return fragment("Session route", `${dir ?? ""}${sessionID ? "/" + sessionID : ""}`) as SessionRouteKey
|
||||
},
|
||||
fromLegacy(key: string) {
|
||||
return fragment("Legacy session route", key) as SessionRouteKey
|
||||
},
|
||||
}
|
||||
|
||||
export const SessionStateKey = {
|
||||
from(scope: ServerScope, route: SessionRouteKey) {
|
||||
return compose(scope, [route]) as SessionStateKey
|
||||
},
|
||||
route(key: string) {
|
||||
const split = key.lastIndexOf(separator)
|
||||
return SessionRouteKey.fromLegacy(split === -1 ? key : key.slice(split + 1))
|
||||
},
|
||||
scope(key: string) {
|
||||
const split = key.indexOf(separator)
|
||||
if (split === -1) return ServerScope.local
|
||||
return fragment("Stored server scope", key.slice(0, split)) as ServerScope
|
||||
},
|
||||
}
|
||||
|
||||
export const ScopedKey = {
|
||||
from(scope: ServerScope, ...parts: string[]) {
|
||||
return compose(scope, parts) as ScopedKey
|
||||
},
|
||||
prefix(scope: ServerScope, ...parts: string[]) {
|
||||
return `${ScopedKey.from(scope, ...parts)}${separator}`
|
||||
},
|
||||
}
|
||||
|
||||
export function migrateLegacySessionStateKeys(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
const entries = Object.entries(value)
|
||||
if (entries.every(([key]) => key.includes(separator))) return value
|
||||
const scoped = Object.fromEntries(entries.filter(([key]) => key.includes(separator)))
|
||||
for (const [key, item] of entries) {
|
||||
if (key.includes(separator)) continue
|
||||
const next = SessionStateKey.from(ServerScope.local, SessionRouteKey.fromLegacy(key))
|
||||
if (!(next in scoped)) scoped[next] = item
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
|
|
@ -1,34 +1,36 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Worktree } from "./worktree"
|
||||
import { ServerScope } from "./server-scope"
|
||||
|
||||
const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}`
|
||||
|
||||
describe("Worktree", () => {
|
||||
const scope = ServerScope.local
|
||||
test("normalizes trailing slashes", () => {
|
||||
const key = dir("normalize")
|
||||
Worktree.ready(`${key}/`)
|
||||
Worktree.ready(scope, `${key}/`)
|
||||
|
||||
expect(Worktree.get(key)).toEqual({ status: "ready" })
|
||||
expect(Worktree.get(scope, key)).toEqual({ status: "ready" })
|
||||
})
|
||||
|
||||
test("pending does not overwrite a terminal state", () => {
|
||||
const key = dir("pending")
|
||||
Worktree.failed(key, "boom")
|
||||
Worktree.pending(key)
|
||||
Worktree.failed(scope, key, "boom")
|
||||
Worktree.pending(scope, key)
|
||||
|
||||
expect(Worktree.get(key)).toEqual({ status: "failed", message: "boom" })
|
||||
expect(Worktree.get(scope, key)).toEqual({ status: "failed", message: "boom" })
|
||||
})
|
||||
|
||||
test("wait resolves shared pending waiter when ready", async () => {
|
||||
const key = dir("wait-ready")
|
||||
Worktree.pending(key)
|
||||
Worktree.pending(scope, key)
|
||||
|
||||
const a = Worktree.wait(key)
|
||||
const b = Worktree.wait(`${key}/`)
|
||||
const a = Worktree.wait(scope, key)
|
||||
const b = Worktree.wait(scope, `${key}/`)
|
||||
|
||||
expect(a).toBe(b)
|
||||
|
||||
Worktree.ready(key)
|
||||
Worktree.ready(scope, key)
|
||||
|
||||
expect(await a).toEqual({ status: "ready" })
|
||||
expect(await b).toEqual({ status: "ready" })
|
||||
|
|
@ -36,11 +38,21 @@ describe("Worktree", () => {
|
|||
|
||||
test("wait resolves with failure message", async () => {
|
||||
const key = dir("wait-failed")
|
||||
const waiting = Worktree.wait(key)
|
||||
const waiting = Worktree.wait(scope, key)
|
||||
|
||||
Worktree.failed(key, "permission denied")
|
||||
Worktree.failed(scope, key, "permission denied")
|
||||
|
||||
expect(await waiting).toEqual({ status: "failed", message: "permission denied" })
|
||||
expect(await Worktree.wait(key)).toEqual({ status: "failed", message: "permission denied" })
|
||||
expect(await Worktree.wait(scope, key)).toEqual({ status: "failed", message: "permission denied" })
|
||||
})
|
||||
|
||||
test("isolates identical directories by server scope", () => {
|
||||
const key = dir("scope")
|
||||
const remote = "https://debian.example" as ServerScope
|
||||
Worktree.ready(scope, key)
|
||||
Worktree.failed(remote, key, "remote failed")
|
||||
|
||||
expect(Worktree.get(scope, key)).toEqual({ status: "ready" })
|
||||
expect(Worktree.get(remote, key)).toEqual({ status: "failed", message: "remote failed" })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
const normalize = (directory: string) => directory.replace(/[\\/]+$/, "")
|
||||
const key = (scope: ServerScope, directory: string) => ScopedKey.from(scope, normalize(directory))
|
||||
|
||||
type State =
|
||||
| {
|
||||
|
|
@ -30,44 +33,44 @@ function deferred() {
|
|||
}
|
||||
|
||||
export const Worktree = {
|
||||
get(directory: string) {
|
||||
return state.get(normalize(directory))
|
||||
get(scope: ServerScope, directory: string) {
|
||||
return state.get(key(scope, directory))
|
||||
},
|
||||
pending(directory: string) {
|
||||
const key = normalize(directory)
|
||||
const current = state.get(key)
|
||||
pending(scope: ServerScope, directory: string) {
|
||||
const id = key(scope, directory)
|
||||
const current = state.get(id)
|
||||
if (current && current.status !== "pending") return
|
||||
state.set(key, { status: "pending" })
|
||||
state.set(id, { status: "pending" })
|
||||
},
|
||||
ready(directory: string) {
|
||||
const key = normalize(directory)
|
||||
ready(scope: ServerScope, directory: string) {
|
||||
const id = key(scope, directory)
|
||||
const next = { status: "ready" } as const
|
||||
state.set(key, next)
|
||||
const waiter = waiters.get(key)
|
||||
state.set(id, next)
|
||||
const waiter = waiters.get(id)
|
||||
if (!waiter) return
|
||||
waiters.delete(key)
|
||||
waiters.delete(id)
|
||||
waiter.resolve(next)
|
||||
},
|
||||
failed(directory: string, message: string) {
|
||||
const key = normalize(directory)
|
||||
failed(scope: ServerScope, directory: string, message: string) {
|
||||
const id = key(scope, directory)
|
||||
const next = { status: "failed", message } as const
|
||||
state.set(key, next)
|
||||
const waiter = waiters.get(key)
|
||||
state.set(id, next)
|
||||
const waiter = waiters.get(id)
|
||||
if (!waiter) return
|
||||
waiters.delete(key)
|
||||
waiters.delete(id)
|
||||
waiter.resolve(next)
|
||||
},
|
||||
wait(directory: string) {
|
||||
const key = normalize(directory)
|
||||
const current = state.get(key)
|
||||
wait(scope: ServerScope, directory: string) {
|
||||
const id = key(scope, directory)
|
||||
const current = state.get(id)
|
||||
if (current && current.status !== "pending") return Promise.resolve(current)
|
||||
|
||||
const existing = waiters.get(key)
|
||||
const existing = waiters.get(id)
|
||||
if (existing) return existing.promise
|
||||
|
||||
const waiter = deferred()
|
||||
|
||||
waiters.set(key, waiter)
|
||||
waiters.set(id, waiter)
|
||||
return waiter.promise
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue