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:
@@ -1,16 +1,27 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
||||||
|
|
||||||
const ChatEmptyState: React.FC = () => {
|
const ChatEmptyState: React.FC = () => {
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const initError = useGlobalSyncStore((s) => s.error);
|
||||||
|
|
||||||
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
|
const textColor = currentTheme?.colors?.surface?.mutedForeground || 'var(--muted-foreground)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
||||||
<OpenChamberLogo width={140} height={140} className="opacity-20" />
|
<OpenChamberLogo width={140} height={140} className="opacity-20" />
|
||||||
<span className="text-body-md" style={{ color: textColor }}>Start a new chat</span>
|
{initError ? (
|
||||||
|
<div className="flex flex-col items-center gap-2 max-w-md text-center px-4">
|
||||||
|
<span className="text-body-md font-medium text-destructive">OpenCode is not reachable</span>
|
||||||
|
<span className="text-body-sm" style={{ color: textColor }}>
|
||||||
|
{initError.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-body-md" style={{ color: textColor }}>Start a new chat</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,7 +50,27 @@ export async function bootstrapGlobal(
|
|||||||
console.error("[bootstrap] global bootstrap failed", errors[0])
|
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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { DirectoryStore } from "./child-store"
|
|||||||
import type { StoreApi } from "zustand"
|
import type { StoreApi } from "zustand"
|
||||||
import { opencodeClient } from "@/lib/opencode/client"
|
import { opencodeClient } from "@/lib/opencode/client"
|
||||||
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||||
|
import { registerSessionDirectory } from "./sync-refs"
|
||||||
|
|
||||||
// Reference set by SyncProvider — allows actions to access SDK and stores
|
// Reference set by SyncProvider — allows actions to access SDK and stores
|
||||||
let _sdk: OpencodeClient | null = null
|
let _sdk: OpencodeClient | null = null
|
||||||
@@ -93,6 +94,11 @@ export async function createSession(
|
|||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|
||||||
const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? 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().setCurrentSession(session.id, sessionDirectory)
|
||||||
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
|
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
|
||||||
useGlobalSessionsStore.getState().upsertSession(session)
|
useGlobalSessionsStore.getState().upsertSession(session)
|
||||||
|
|||||||
@@ -194,7 +194,9 @@ const normalizeEventDirectory = (rawDirectory: string): string => {
|
|||||||
if (!rawDirectory || rawDirectory === "global") {
|
if (!rawDirectory || rawDirectory === "global") {
|
||||||
return rawDirectory
|
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 => {
|
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 = (
|
const resolveDirectoryFromRoutingIndex = (
|
||||||
routingIndex: EventRoutingIndex,
|
routingIndex: EventRoutingIndex,
|
||||||
rawDirectory: string,
|
rawDirectory: string,
|
||||||
@@ -433,6 +455,13 @@ const resolveDirectoryFromRoutingIndex = (
|
|||||||
if (indexedDirectory) {
|
if (indexedDirectory) {
|
||||||
return 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)
|
const messageID = getMessageIdFromPayload(payload)
|
||||||
@@ -444,8 +473,16 @@ const resolveDirectoryFromRoutingIndex = (
|
|||||||
return indexedDirectory
|
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 (
|
if (
|
||||||
(sessionID || messageID)
|
(sessionID || messageID)
|
||||||
&& (!normalizedDirectory || normalizedDirectory === "global")
|
&& (!normalizedDirectory || normalizedDirectory === "global")
|
||||||
@@ -653,7 +690,22 @@ function handleEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Directory events
|
// 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) {
|
if (!store) {
|
||||||
// Try as global event for unknown directories
|
// Try as global event for unknown directories
|
||||||
@@ -669,7 +721,7 @@ function handleEvent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
childStores.mark(directory)
|
childStores.mark(resolvedDirectory)
|
||||||
|
|
||||||
// Notification dispatch for session turn-complete and error events.
|
// Notification dispatch for session turn-complete and error events.
|
||||||
// These are NOT handled by the event reducer — only the notification store.
|
// These are NOT handled by the event reducer — only the notification store.
|
||||||
@@ -683,10 +735,10 @@ function handleEvent(
|
|||||||
// subtask — skip notification
|
// subtask — skip notification
|
||||||
} else if (sessionID) {
|
} else if (sessionID) {
|
||||||
appendNotification({
|
appendNotification({
|
||||||
directory,
|
directory: resolvedDirectory,
|
||||||
session: sessionID,
|
session: sessionID,
|
||||||
time: Date.now(),
|
time: Date.now(),
|
||||||
viewed: isViewedInCurrentSession(directory, sessionID),
|
viewed: isViewedInCurrentSession(resolvedDirectory, sessionID),
|
||||||
...(payload.type === "session.error"
|
...(payload.type === "session.error"
|
||||||
? { type: "error" as const, error: props.error }
|
? { type: "error" as const, error: props.error }
|
||||||
: { type: "turn-complete" as const }),
|
: { type: "turn-complete" as const }),
|
||||||
@@ -752,7 +804,7 @@ function handleEvent(
|
|||||||
store.setState(draft)
|
store.setState(draft)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRoutingIndexFromEvent(routingIndex, directory, payload)
|
updateRoutingIndexFromEvent(routingIndex, resolvedDirectory, payload)
|
||||||
|
|
||||||
// Update global session status for cross-directory sidebar visibility
|
// Update global session status for cross-directory sidebar visibility
|
||||||
if (payload.type === "session.status") {
|
if (payload.type === "session.status") {
|
||||||
@@ -761,15 +813,15 @@ function handleEvent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "permission.asked") {
|
if (payload.type === "permission.asked") {
|
||||||
const normalizedDirectory = normalizeDirectory(directory)
|
const nd = normalizeDirectory(resolvedDirectory)
|
||||||
if (!normalizedDirectory) {
|
if (!nd) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = payload.properties as PermissionRequest
|
const permission = payload.properties as PermissionRequest
|
||||||
const sessions = store.getState().session
|
const sessions = store.getState().session
|
||||||
const autoAccept = usePermissionStore.getState().autoAccept
|
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)
|
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
|
// Set refs so non-React code (session-actions, session-ui-store) can access sync state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSyncRefs(props.sdk, childStores, props.directory)
|
setSyncRefs(props.sdk, childStores, props.directory, (sessionID, dir) => {
|
||||||
|
setIndexedSessionDirectory(routingIndex, sessionID, dir)
|
||||||
|
})
|
||||||
setActionRefs(
|
setActionRefs(
|
||||||
props.sdk,
|
props.sdk,
|
||||||
childStores,
|
childStores,
|
||||||
() => opencodeClient.getDirectory() || props.directory,
|
() => opencodeClient.getDirectory() || props.directory,
|
||||||
)
|
)
|
||||||
}, [props.sdk, props.directory, childStores])
|
}, [props.sdk, props.directory, childStores, routingIndex])
|
||||||
|
|
||||||
// Subscribe to child store for streaming state derivation
|
// Subscribe to child store for streaming state derivation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -12,15 +12,27 @@ import type { State } from "./types"
|
|||||||
let _sdk: OpencodeClient | null = null
|
let _sdk: OpencodeClient | null = null
|
||||||
let _childStores: ChildStoreManager | null = null
|
let _childStores: ChildStoreManager | null = null
|
||||||
let _directory: string = ""
|
let _directory: string = ""
|
||||||
|
let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null
|
||||||
|
|
||||||
export function setSyncRefs(
|
export function setSyncRefs(
|
||||||
sdk: OpencodeClient,
|
sdk: OpencodeClient,
|
||||||
childStores: ChildStoreManager,
|
childStores: ChildStoreManager,
|
||||||
directory: string,
|
directory: string,
|
||||||
|
registerSessionDirectory?: (sessionID: string, directory: string) => void,
|
||||||
) {
|
) {
|
||||||
_sdk = sdk
|
_sdk = sdk
|
||||||
_childStores = childStores
|
_childStores = childStores
|
||||||
_directory = directory
|
_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 {
|
export function getSyncSDK(): OpencodeClient {
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://127.0.0.1:${state.openCodePort}/session`, {
|
const response = await fetch(buildOpenCodeUrl('/session', ''), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: getOpenCodeAuthHeaders(),
|
headers: getOpenCodeAuthHeaders(),
|
||||||
signal: AbortSignal.timeout(2000),
|
signal: AbortSignal.timeout(2000),
|
||||||
|
|||||||
@@ -31,6 +31,45 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
app.set('opencodeProxyConfigured', true);
|
app.set('opencodeProxyConfigured', true);
|
||||||
|
|
||||||
const isAbortError = (error) => error?.name === 'AbortError';
|
const isAbortError = (error) => error?.name === 'AbortError';
|
||||||
|
const FALLBACK_PROXY_TARGET = 'http://127.0.0.1:3902';
|
||||||
|
|
||||||
|
const normalizeProxyTarget = (candidate) => {
|
||||||
|
if (typeof candidate !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep generic proxy requests on the same upstream base URL that health checks
|
||||||
|
// and direct fetch helpers use. This avoids split-brain state where /health
|
||||||
|
// succeeds against an external host but /api/* still proxies to 127.0.0.1.
|
||||||
|
const resolveProxyTarget = () => {
|
||||||
|
try {
|
||||||
|
const resolved = normalizeProxyTarget(buildOpenCodeUrl('/', ''));
|
||||||
|
if (resolved) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeState = getRuntime();
|
||||||
|
const externalBase = normalizeProxyTarget(runtimeState.openCodeBaseUrl);
|
||||||
|
if (externalBase) {
|
||||||
|
return externalBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeState.openCodePort) {
|
||||||
|
return `http://localhost:${runtimeState.openCodePort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FALLBACK_PROXY_TARGET;
|
||||||
|
};
|
||||||
|
|
||||||
const forwardSseRequest = async (req, res) => {
|
const forwardSseRequest = async (req, res) => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -79,6 +118,12 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
res.flushHeaders();
|
res.flushHeaders();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable TCP Nagle's algorithm so small SSE chunks are sent immediately
|
||||||
|
// instead of being buffered up to ~200ms by the TCP stack.
|
||||||
|
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
||||||
|
res.socket.setNoDelay(true);
|
||||||
|
}
|
||||||
|
|
||||||
reader = upstream.body.getReader();
|
reader = upstream.body.getReader();
|
||||||
while (!abortController.signal.aborted) {
|
while (!abortController.signal.aborted) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -233,14 +278,11 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|||||||
|
|
||||||
// Generic proxy for non-SSE OpenCode API routes.
|
// Generic proxy for non-SSE OpenCode API routes.
|
||||||
const apiProxy = createProxyMiddleware({
|
const apiProxy = createProxyMiddleware({
|
||||||
target: `http://127.0.0.1:${runtime.openCodePort || 3902}`,
|
target: resolveProxyTarget(),
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { '^/api': '' },
|
pathRewrite: { '^/api': '' },
|
||||||
// Dynamic target — port can change after restart
|
// Dynamic target — port can change after restart
|
||||||
router: () => {
|
router: () => resolveProxyTarget(),
|
||||||
const rt = getRuntime();
|
|
||||||
return `http://127.0.0.1:${rt.openCodePort || 3902}`;
|
|
||||||
},
|
|
||||||
on: {
|
on: {
|
||||||
proxyReq: (proxyReq) => {
|
proxyReq: (proxyReq) => {
|
||||||
// Inject OpenCode auth headers
|
// Inject OpenCode auth headers
|
||||||
|
|||||||
Reference in New Issue
Block a user