fix: restore remote OpenCode providers and startup errors

- Fix OpenChamber proxying to the correct OpenCode host for remote VPS setups
- Show a clear empty-state error when OpenCode is not reachable
- Harden session event routing for early or mismatched directory events
This commit is contained in:
Bohdan Triapitsyn
2026-04-07 01:17:12 +03:00
parent 16a4157440
commit db19f83f46
7 changed files with 164 additions and 19 deletions
+21 -1
View File
@@ -50,7 +50,27 @@ export async function bootstrapGlobal(
console.error("[bootstrap] global bootstrap failed", errors[0])
}
set({ ready: true })
// 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 fetch("/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 })
}
}
// ---------------------------------------------------------------------------
+6
View File
@@ -11,6 +11,7 @@ import type { DirectoryStore } from "./child-store"
import type { StoreApi } from "zustand"
import { opencodeClient } from "@/lib/opencode/client"
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { registerSessionDirectory } from "./sync-refs"
// Reference set by SyncProvider — allows actions to access SDK and stores
let _sdk: OpencodeClient | null = null
@@ -93,6 +94,11 @@ export async function createSession(
if (!session) return null
const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null
// Pre-populate routing index so SSE events arriving before session.created
// can be routed to the correct child store
if (sessionDirectory) {
registerSessionDirectory(session.id, sessionDirectory)
}
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory)
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
+65 -11
View File
@@ -194,7 +194,9 @@ const normalizeEventDirectory = (rawDirectory: string): string => {
if (!rawDirectory || rawDirectory === "global") {
return rawDirectory
}
return rawDirectory.replace(/\\/g, "/").replace(/^([a-z]):/, (_, l: string) => l.toUpperCase() + ":")
const normalized = rawDirectory.replace(/\\/g, "/").replace(/^([a-z]):/, (_, l: string) => l.toUpperCase() + ":")
// Strip trailing slashes to match child store keys (normalizeDirectoryPath in useDirectoryStore)
return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized
}
const getSessionIdFromPayload = (event: Event): string | null => {
@@ -419,6 +421,26 @@ const ingestDirectoryStateIntoRoutingIndex = (
}
}
const findSessionInChildStores = (
sessionID: string,
childStores: ChildStoreManager,
routingIndex: EventRoutingIndex,
): string | null => {
for (const [dir, store] of childStores.children) {
const state = store.getState()
if (
state.session.some((s) => s.id === sessionID)
|| Object.prototype.hasOwnProperty.call(state.message, sessionID)
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionID)
) {
// Self-heal: populate the routing index so future events resolve instantly
setIndexedSessionDirectory(routingIndex, sessionID, dir)
return dir
}
}
return null
}
const resolveDirectoryFromRoutingIndex = (
routingIndex: EventRoutingIndex,
rawDirectory: string,
@@ -433,6 +455,13 @@ const resolveDirectoryFromRoutingIndex = (
if (indexedDirectory) {
return indexedDirectory
}
// Routing index miss — scan child stores for this session.
// Covers optimistic sessions not yet indexed and events with wrong/empty directory.
const found = findSessionInChildStores(sessionID, childStores, routingIndex)
if (found) {
return found
}
}
const messageID = getMessageIdFromPayload(payload)
@@ -444,8 +473,16 @@ const resolveDirectoryFromRoutingIndex = (
return indexedDirectory
}
}
// Scan child stores for a store that has parts for this message
for (const [dir, store] of childStores.children) {
if (Object.prototype.hasOwnProperty.call(store.getState().part, messageID)) {
return dir
}
}
}
// Single-store fallback: if there's only one directory, use it
if (
(sessionID || messageID)
&& (!normalizedDirectory || normalizedDirectory === "global")
@@ -653,7 +690,22 @@ function handleEvent(
}
// Directory events
const store = childStores.getChild(directory)
let store = childStores.getChild(directory)
let resolvedDirectory = directory
if (!store) {
// Store not found for this directory — attempt recovery by scanning
// child stores for the session. This handles directory mismatches
// (trailing slashes, case differences, events with wrong directory).
const sessionID = getSessionIdFromPayload(payload)
if (sessionID) {
const fallbackDir = findSessionInChildStores(sessionID, childStores, routingIndex)
if (fallbackDir) {
store = childStores.getChild(fallbackDir)
resolvedDirectory = fallbackDir
}
}
}
if (!store) {
// Try as global event for unknown directories
@@ -669,7 +721,7 @@ function handleEvent(
return
}
childStores.mark(directory)
childStores.mark(resolvedDirectory)
// Notification dispatch for session turn-complete and error events.
// These are NOT handled by the event reducer — only the notification store.
@@ -683,10 +735,10 @@ function handleEvent(
// subtask — skip notification
} else if (sessionID) {
appendNotification({
directory,
directory: resolvedDirectory,
session: sessionID,
time: Date.now(),
viewed: isViewedInCurrentSession(directory, sessionID),
viewed: isViewedInCurrentSession(resolvedDirectory, sessionID),
...(payload.type === "session.error"
? { type: "error" as const, error: props.error }
: { type: "turn-complete" as const }),
@@ -752,7 +804,7 @@ function handleEvent(
store.setState(draft)
}
updateRoutingIndexFromEvent(routingIndex, directory, payload)
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
// Update global session status for cross-directory sidebar visibility
if (payload.type === "session.status") {
@@ -761,15 +813,15 @@ function handleEvent(
}
if (payload.type === "permission.asked") {
const normalizedDirectory = normalizeDirectory(directory)
if (!normalizedDirectory) {
const nd = normalizeDirectory(resolvedDirectory)
if (!nd) {
return
}
const permission = payload.properties as PermissionRequest
const sessions = store.getState().session
const autoAccept = usePermissionStore.getState().autoAccept
if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: normalizedDirectory })) {
if (autoRespondsPermission({ autoAccept, sessions, sessionID: permission.sessionID, directory: nd })) {
void sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined)
}
}
@@ -928,13 +980,15 @@ export function SyncProvider(props: {
// Set refs so non-React code (session-actions, session-ui-store) can access sync state
useEffect(() => {
setSyncRefs(props.sdk, childStores, props.directory)
setSyncRefs(props.sdk, childStores, props.directory, (sessionID, dir) => {
setIndexedSessionDirectory(routingIndex, sessionID, dir)
})
setActionRefs(
props.sdk,
childStores,
() => opencodeClient.getDirectory() || props.directory,
)
}, [props.sdk, props.directory, childStores])
}, [props.sdk, props.directory, childStores, routingIndex])
// Subscribe to child store for streaming state derivation
useEffect(() => {
+12
View File
@@ -12,15 +12,27 @@ import type { State } from "./types"
let _sdk: OpencodeClient | null = null
let _childStores: ChildStoreManager | null = null
let _directory: string = ""
let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null
export function setSyncRefs(
sdk: OpencodeClient,
childStores: ChildStoreManager,
directory: string,
registerSessionDirectory?: (sessionID: string, directory: string) => void,
) {
_sdk = sdk
_childStores = childStores
_directory = directory
if (registerSessionDirectory) {
_registerSessionDirectory = registerSessionDirectory
}
}
/** Pre-register a session→directory mapping in the routing index.
* Called from session-actions when creating sessions so SSE events
* arriving before session.created can be routed correctly. */
export function registerSessionDirectory(sessionID: string, directory: string) {
_registerSessionDirectory?.(sessionID, directory)
}
export function getSyncSDK(): OpencodeClient {