Improve OpenChamber responsiveness under large session workloads while fixing cache, synchronization, and persistence correctness across runtimes, projects, directories, and worktrees. - prioritize selected and visible sessions during bootstrap and defer non-critical enrichment work - reduce redundant message loading, event processing, store publication, and hidden sidebar work - prevent stale session and message requests from overwriting newer authoritative state - preserve existing data when authoritative fetches fail instead of treating failures as successful empty responses - scope session materialization, messages, drafts, queues, todos, pins, permissions, folders, tabs, Git state, and pull request data by runtime and directory identity - harden runtime switching, reconnect, cleanup, mutation reconciliation, and persisted-state ordering - preserve live subagent Task linkage when metadata arrives after an older message request or while streaming parts are suspended - coalesce overlapping tail refreshes without losing newer refresh demand - improve cold-session loading by moving deferrable work out of the critical bootstrap path - isolate URL authentication, mobile credentials, native secrets, and other runtime-owned state across endpoint changes - bound long-lived caches and remove avoidable allocations from event and rendering hot paths - limit virtualization to archive collections where it improves rendering without disrupting active sidebar layout - stabilize session folders, pin ordering, expanded state, and persisted sidebar behavior - open skill files through the same secure editor and outside-workspace grant flow used by file navigation, including worktree sessions - expand regression coverage for stale completions, runtime collisions, reconnect behavior, persistence races, authoritative empty results, and subagent refresh ordering - document the updated synchronization, cache ownership, performance, and runtime-isolation invariants
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import type { OpencodeClient, PermissionRequest, Project, QuestionRequest } from "@opencode-ai/sdk/v2/client"
|
|
import { retry } from "./retry"
|
|
import type { GlobalState, State } from "./types"
|
|
import { runtimeFetch } from "../lib/runtime-fetch"
|
|
import { emitSyncConfigChanged } from "./sync-refs"
|
|
|
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
|
|
|
/**
|
|
* SDK returns `{ data, error, response }` without throwing on non-2xx.
|
|
* The silent `x.data!` / `x.data ?? []` pattern lets HTTP 5xx warmup
|
|
* errors become empty state. Wrap into a real Error so retry() fires.
|
|
*/
|
|
function unwrap<T>(
|
|
result: { data?: T; error?: unknown; response?: { status?: number } },
|
|
name: string,
|
|
): T {
|
|
if (result.error) {
|
|
const rawError = result.error
|
|
const status = result.response?.status
|
|
const message = typeof rawError === "object" && rawError !== null && "message" in rawError
|
|
? String((rawError as { message?: unknown }).message)
|
|
: String(rawError)
|
|
const err = new Error(`${name} failed${status ? ` (${status})` : ""}: ${message}`)
|
|
if (status !== undefined) {
|
|
;(err as Error & { status?: number }).status = status
|
|
}
|
|
throw err
|
|
}
|
|
if (result.data === undefined) {
|
|
// No error + no data: ambiguous, treat as transient so retry fires.
|
|
const err = new Error(`${name} returned no data`)
|
|
;(err as Error & { status?: number }).status = 503
|
|
throw err
|
|
}
|
|
return result.data
|
|
}
|
|
|
|
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
|
if (!items || items.length === 0) return ""
|
|
return items
|
|
.map((item) => item.id)
|
|
.sort(cmp)
|
|
.join("|")
|
|
}
|
|
|
|
function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
|
|
return input.reduce<Record<string, T[]>>((acc, item) => {
|
|
if (!item?.id || !item.sessionID) return acc
|
|
const list = acc[item.sessionID]
|
|
if (list) list.push(item)
|
|
else acc[item.sessionID] = [item]
|
|
return acc
|
|
}, {})
|
|
}
|
|
|
|
function projectID(directory: string, projects: Project[]) {
|
|
return projects.find(
|
|
(project) => project.worktree === directory || project.sandboxes?.includes(directory),
|
|
)?.id
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap global state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function bootstrapGlobal(
|
|
sdk: OpencodeClient,
|
|
set: (patch: Partial<GlobalState>) => void,
|
|
) {
|
|
const results = await Promise.allSettled([
|
|
retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))),
|
|
retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
|
|
retry(() =>
|
|
sdk.project.list().then((x) => {
|
|
const data = unwrap(x, "project.list")
|
|
const projects = data
|
|
.filter((p): p is Project => !!p?.id)
|
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
set({ projects })
|
|
}),
|
|
),
|
|
])
|
|
|
|
const errors = results
|
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
.map((r) => r.reason)
|
|
if (errors.length) {
|
|
console.error("[bootstrap] global bootstrap failed", errors[0])
|
|
}
|
|
|
|
// If ALL requests failed, OpenCode is likely down — fetch the OpenChamber
|
|
// health endpoint (outside the readiness gate) to get the actual error reason.
|
|
if (errors.length === results.length) {
|
|
let message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
|
|
try {
|
|
const healthRes = await runtimeFetch('/health', { signal: AbortSignal.timeout(4000) })
|
|
if (healthRes.ok) {
|
|
const health = await healthRes.json()
|
|
if (health.lastOpenCodeError) {
|
|
message = health.lastOpenCodeError
|
|
} else if (!health.openCodeRunning) {
|
|
message = "OpenCode process is not running"
|
|
}
|
|
}
|
|
} catch {
|
|
// health endpoint itself unreachable — use the original error
|
|
}
|
|
set({ ready: true, error: { type: "init", message } })
|
|
} else {
|
|
set({ ready: true, error: undefined })
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bootstrap per-directory state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function bootstrapDirectory(input: {
|
|
directory: string
|
|
sdk: OpencodeClient
|
|
getState: () => State
|
|
set: (patch: Partial<State>) => void
|
|
isStale?: () => boolean
|
|
global: {
|
|
config: Record<string, unknown>
|
|
projects: Project[]
|
|
}
|
|
loadSessions: (directory: string) => Promise<void> | void
|
|
}): Promise<"complete" | "failed" | "stale"> {
|
|
const { directory, sdk, getState, set, global: g } = input
|
|
const commit = (patch: Partial<State>): boolean => {
|
|
if (input.isStale?.()) return false
|
|
set(patch)
|
|
return true
|
|
}
|
|
const state = getState()
|
|
const loading = state.status !== "complete"
|
|
|
|
// Seed from global state while we fetch directory-specific data
|
|
const seededProject = projectID(directory, g.projects)
|
|
if (seededProject) commit({ project: seededProject })
|
|
if (Object.keys(state.config ?? {}).length === 0 && Object.keys(g.config ?? {}).length > 0) {
|
|
const seededConfig = g.config as State["config"]
|
|
if (commit({ config: seededConfig })) emitSyncConfigChanged(directory, seededConfig)
|
|
}
|
|
if (loading) commit({ status: "partial" })
|
|
if (input.isStale?.()) return "stale"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 1: Critical path — block until these resolve so the UI can render.
|
|
// These are the minimum data needed to show a functional chat interface.
|
|
// ---------------------------------------------------------------------------
|
|
const phase1Results = await Promise.allSettled([
|
|
seededProject
|
|
? Promise.resolve()
|
|
: retry(() => sdk.project.current().then((x) => commit({ project: unwrap(x, "project.current").id }))),
|
|
retry(() => sdk.config.get().then((x) => {
|
|
const config = unwrap(x, "config.get")
|
|
if (commit({ config })) emitSyncConfigChanged(directory, config)
|
|
})),
|
|
retry(() =>
|
|
sdk.path.get().then((x) => {
|
|
const data = unwrap(x, "path.get")
|
|
commit({ path: data })
|
|
const next = projectID(data?.directory ?? directory, g.projects)
|
|
if (next) commit({ project: next })
|
|
}),
|
|
),
|
|
retry(() => sdk.session.status().then((x) => commit({ session_status: unwrap(x, "session.status") }))),
|
|
])
|
|
|
|
if (input.isStale?.()) return "stale"
|
|
|
|
const phase1Errors = phase1Results
|
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
.map((r) => r.reason)
|
|
|
|
// De-block the UI: only a total failure (OpenCode genuinely unreachable)
|
|
// should abort the directory. Don't let one transient initial fetch strand
|
|
// the directory in "loading" forever and skip phase 2/3 (sessions).
|
|
// - session.status is LIVE data the event pipeline keeps current — a failed
|
|
// initial snapshot is harmless; SSE will deliver the real status.
|
|
// - path.get feeds project resolution, but if we already resolved a project
|
|
// (from global projects) its failure is tolerable; the worktree path is
|
|
// refreshed by later events.
|
|
const [, , pathResult] = phase1Results
|
|
const pathFailedWithoutProject =
|
|
pathResult.status === "rejected" && !getState().project
|
|
|
|
if (phase1Errors.length === phase1Results.length || pathFailedWithoutProject) {
|
|
console.error(`[bootstrap] directory bootstrap failed for ${directory}`, phase1Errors[0])
|
|
return "failed"
|
|
}
|
|
|
|
// Mark ready after critical data arrives so the UI can paint.
|
|
if (loading) commit({ status: "complete" })
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 2: Deferrable — fetch after first paint without blocking.
|
|
// These enrich the UI but aren't required for basic functionality.
|
|
// ---------------------------------------------------------------------------
|
|
const runDeferredPhase = () => Promise.allSettled([
|
|
retry(() => sdk.command.list().then((x) => commit({ command: unwrap(x, "command.list") }))),
|
|
retry(() => sdk.mcp.status().then((x) => commit({ mcp: unwrap(x, "mcp.status") }))),
|
|
retry(() => sdk.lsp.status().then((x) => commit({ lsp: unwrap(x, "lsp.status") }))),
|
|
retry(() =>
|
|
sdk.vcs.get().then((x) => {
|
|
const current = getState()
|
|
if (x.error) {
|
|
throw new Error(`vcs.get failed: ${String(x.error)}`)
|
|
}
|
|
commit({ vcs: x.data ?? current.vcs })
|
|
}),
|
|
),
|
|
retry(async () => {
|
|
const before = getState()
|
|
const beforeSignatures = new Map(
|
|
Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]),
|
|
)
|
|
const x = await sdk.question.list(directory ? { directory } : undefined)
|
|
if (x.error) {
|
|
const status = (x as { response?: { status?: number } }).response?.status
|
|
const err = new Error(`question.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
|
if (status !== undefined) (err as Error & { status?: number }).status = status
|
|
throw err
|
|
}
|
|
const grouped = groupBySession(
|
|
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
|
|
)
|
|
const current = getState()
|
|
const merged = { ...current.question }
|
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
|
merged[sessionID] = questions
|
|
.filter((q) => !!q?.id)
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
}
|
|
for (const sessionID of beforeSignatures.keys()) {
|
|
if (grouped[sessionID]) continue
|
|
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
|
const currentSignature = requestSignature(current.question[sessionID])
|
|
if (currentSignature !== beforeSignature) continue
|
|
delete merged[sessionID]
|
|
}
|
|
commit({ question: merged })
|
|
}),
|
|
retry(async () => {
|
|
const before = getState()
|
|
const beforeSignatures = new Map(
|
|
Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]),
|
|
)
|
|
const x = await sdk.permission.list(directory ? { directory } : undefined)
|
|
if (x.error) {
|
|
const status = (x as { response?: { status?: number } }).response?.status
|
|
const err = new Error(`permission.list failed${status ? ` (${status})` : ""}: ${String(x.error)}`)
|
|
if (status !== undefined) (err as Error & { status?: number }).status = status
|
|
throw err
|
|
}
|
|
const grouped = groupBySession(
|
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
|
|
)
|
|
const current = getState()
|
|
const merged = { ...current.permission }
|
|
for (const [sessionID, perms] of Object.entries(grouped)) {
|
|
merged[sessionID] = perms
|
|
.filter((p) => !!p?.id)
|
|
.sort((a, b) => cmp(a.id, b.id))
|
|
}
|
|
for (const sessionID of beforeSignatures.keys()) {
|
|
if (grouped[sessionID]) continue
|
|
const beforeSignature = beforeSignatures.get(sessionID) ?? ""
|
|
const currentSignature = requestSignature(current.permission[sessionID])
|
|
if (currentSignature !== beforeSignature) continue
|
|
delete merged[sessionID]
|
|
}
|
|
commit({ permission: merged })
|
|
}),
|
|
]).then((results) => {
|
|
const errors = results
|
|
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
|
|
.map((r) => r.reason)
|
|
if (errors.length) {
|
|
console.error(`[bootstrap] deferred phase failed for ${directory}`, errors[0])
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 3: Authoritative session list. Keep this scheduler-owned so bounded
|
|
// bootstrap concurrency also bounds list pagination, but do not hold the slot
|
|
// for the deferrable enrichment phase above.
|
|
// ---------------------------------------------------------------------------
|
|
const sessionsResult = await Promise.allSettled([Promise.resolve(input.loadSessions(directory))])
|
|
if (input.isStale?.()) return "stale"
|
|
const sessionLoad = sessionsResult[0]
|
|
setTimeout(() => {
|
|
if (!input.isStale?.()) void runDeferredPhase()
|
|
}, 0)
|
|
if (sessionLoad?.status === "rejected") {
|
|
console.error(`[bootstrap] session load failed for ${directory}`, sessionLoad.reason)
|
|
return "failed"
|
|
}
|
|
return "complete"
|
|
}
|