fix: don't silently swallow bootstrap SDK errors
sdk.path.get() / sdk.project.list() / sdk.app.agents() etc. return
{ data, error, response } without throwing on non-2xx. The x.data! /
x.data ?? [] pattern turned HTTP 5xx warmup responses into empty state,
and retry() never saw an error to retry.
Wrap everything through unwrap(): preserve the HTTP status on the
thrown Error so retry()'s transient detection (5xx) fires. Fixes the
class of bugs where a slow opencode sidecar leaves agents/commands/
sessions looking empty after startup.
This commit is contained in:
@@ -4,6 +4,36 @@ import type { GlobalState, State } from "./types"
|
|||||||
|
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
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 => {
|
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
||||||
if (!items || items.length === 0) return ""
|
if (!items || items.length === 0) return ""
|
||||||
return items
|
return items
|
||||||
@@ -37,18 +67,19 @@ export async function bootstrapGlobal(
|
|||||||
set: (patch: Partial<GlobalState>) => void,
|
set: (patch: Partial<GlobalState>) => void,
|
||||||
) {
|
) {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
retry(() => sdk.path.get().then((x) => set({ path: x.data! }))),
|
retry(() => sdk.path.get().then((x) => set({ path: unwrap(x, "path.get") }))),
|
||||||
retry(() => sdk.global.config.get().then((x) => set({ config: x.data! }))),
|
retry(() => sdk.global.config.get().then((x) => set({ config: unwrap(x, "global.config.get") }))),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.project.list().then((x) => {
|
sdk.project.list().then((x) => {
|
||||||
const projects = (x.data ?? [])
|
const data = unwrap(x, "project.list")
|
||||||
|
const projects = data
|
||||||
.filter((p): p is Project => !!p?.id)
|
.filter((p): p is Project => !!p?.id)
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
set({ projects })
|
set({ projects })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
retry(() => sdk.provider.list().then((x) => set({ providers: x.data! }))),
|
retry(() => sdk.provider.list().then((x) => set({ providers: unwrap(x, "provider.list") }))),
|
||||||
])
|
])
|
||||||
|
|
||||||
const errors = results
|
const errors = results
|
||||||
@@ -115,25 +146,30 @@ export async function bootstrapDirectory(input: {
|
|||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
seededProject
|
seededProject
|
||||||
? Promise.resolve()
|
? Promise.resolve()
|
||||||
: retry(() => sdk.project.current().then((x) => set({ project: x.data!.id }))),
|
: retry(() => sdk.project.current().then((x) => set({ project: unwrap(x, "project.current").id }))),
|
||||||
retry(() => sdk.provider.list().then((x) => set({ provider: x.data! }))),
|
retry(() => sdk.provider.list().then((x) => set({ provider: unwrap(x, "provider.list") }))),
|
||||||
retry(() => sdk.app.agents().then((x) => set({ agent: x.data ?? [] }))),
|
retry(() => sdk.app.agents().then((x) => set({ agent: unwrap(x, "app.agents") }))),
|
||||||
retry(() => sdk.config.get().then((x) => set({ config: x.data! }))),
|
retry(() => sdk.config.get().then((x) => set({ config: unwrap(x, "config.get") }))),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.path.get().then((x) => {
|
sdk.path.get().then((x) => {
|
||||||
set({ path: x.data! })
|
const data = unwrap(x, "path.get")
|
||||||
const next = projectID(x.data?.directory ?? directory, g.projects)
|
set({ path: data })
|
||||||
|
const next = projectID(data?.directory ?? directory, g.projects)
|
||||||
if (next) set({ project: next })
|
if (next) set({ project: next })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
retry(() => sdk.command.list().then((x) => set({ command: x.data ?? [] }))),
|
retry(() => sdk.command.list().then((x) => set({ command: unwrap(x, "command.list") }))),
|
||||||
retry(() => sdk.session.status().then((x) => set({ session_status: x.data! }))),
|
retry(() => sdk.session.status().then((x) => set({ session_status: unwrap(x, "session.status") }))),
|
||||||
input.loadSessions(directory),
|
input.loadSessions(directory),
|
||||||
retry(() => sdk.mcp.status().then((x) => set({ mcp: x.data! }))),
|
retry(() => sdk.mcp.status().then((x) => set({ mcp: unwrap(x, "mcp.status") }))),
|
||||||
retry(() => sdk.lsp.status().then((x) => set({ lsp: x.data! }))),
|
retry(() => sdk.lsp.status().then((x) => set({ lsp: unwrap(x, "lsp.status") }))),
|
||||||
retry(() =>
|
retry(() =>
|
||||||
sdk.vcs.get().then((x) => {
|
sdk.vcs.get().then((x) => {
|
||||||
const current = getState()
|
const current = getState()
|
||||||
|
// vcs is optional — fall back to current if server omits it.
|
||||||
|
if (x.error) {
|
||||||
|
throw new Error(`vcs.get failed: ${String(x.error)}`)
|
||||||
|
}
|
||||||
set({ vcs: x.data ?? current.vcs })
|
set({ vcs: x.data ?? current.vcs })
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -143,6 +179,12 @@ export async function bootstrapDirectory(input: {
|
|||||||
Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]),
|
Object.entries(before.question ?? {}).map(([sessionID, questions]) => [sessionID, requestSignature(questions)]),
|
||||||
)
|
)
|
||||||
const x = await sdk.question.list(directory ? { directory } : undefined)
|
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(
|
const grouped = groupBySession(
|
||||||
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
|
(x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID),
|
||||||
)
|
)
|
||||||
@@ -168,6 +210,12 @@ export async function bootstrapDirectory(input: {
|
|||||||
Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]),
|
Object.entries(before.permission ?? {}).map(([sessionID, permissions]) => [sessionID, requestSignature(permissions)]),
|
||||||
)
|
)
|
||||||
const x = await sdk.permission.list(directory ? { directory } : undefined)
|
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(
|
const grouped = groupBySession(
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm?.sessionID),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user