mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-10 17:03:36 +00:00
fix(workspaces): surface invalid OpenCode configuration (#635)
## Summary - validate the authenticated OpenCode process configuration before publishing a workspace as ready - stop and remove instances that report invalid configuration - show localized configuration diagnostics, affected paths, validation issues, and typo suggestions in the existing launch-error dialog ## Existing coverage PR #195 already handles binary spawn and early-exit failures. PR #582 surfaces session-list loading failures. This change closes the remaining case where OpenCode reports healthy while configuration-dependent endpoints fail. ## Validation - `bun test ./packages/server/src/workspaces/manager.test.ts` - `node --import tsx --test packages/ui/src/lib/launch-errors.test.ts` - server and UI typechecks - UI build - manual reproduction with OpenCode 1.18.5 - independent Gatekeeper reviews: PASS Closes #508
This commit is contained in:
parent
afb09d94c8
commit
c16cc005f3
16 changed files with 200 additions and 28 deletions
1
.github/workflows/pr-build.yml
vendored
1
.github/workflows/pr-build.yml
vendored
|
|
@ -107,6 +107,7 @@ jobs:
|
|||
packages/ui/src/components/session-list-visibility.test.ts
|
||||
packages/ui/src/components/unified-picker-path.test.ts
|
||||
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
|
||||
packages/ui/src/lib/launch-errors.test.ts
|
||||
packages/ui/src/lib/message-selection-position.test.ts
|
||||
packages/ui/src/lib/trailing-resync.test.ts
|
||||
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
|
||||
|
|
|
|||
|
|
@ -59,11 +59,13 @@ class ControlledRuntime {
|
|||
}
|
||||
|
||||
function createHarness(options: {
|
||||
stubReadiness?: boolean
|
||||
shutdownTimeoutMs?: number
|
||||
launchTimeoutMs?: number
|
||||
setTimeout?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>
|
||||
clearTimeout?: (timer: ReturnType<typeof setTimeout>) => void
|
||||
} = {}) {
|
||||
const { stubReadiness = true, ...managerOptions } = options
|
||||
const eventBus = new EventBus()
|
||||
const runtime = new ControlledRuntime()
|
||||
const readiness = deferred<string | undefined>()
|
||||
|
|
@ -79,16 +81,18 @@ function createHarness(options: {
|
|||
logger: pino({ level: "silent" }),
|
||||
getServerBaseUrl: () => "http://127.0.0.1:4000",
|
||||
runtime,
|
||||
...options,
|
||||
...managerOptions,
|
||||
})
|
||||
;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([
|
||||
readiness.promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
const cancel = () => reject(signal?.reason)
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
if (signal?.aborted) cancel()
|
||||
}),
|
||||
])
|
||||
if (stubReadiness) {
|
||||
;(manager as any).waitForWorkspaceReadiness = ({ signal }: { signal?: AbortSignal }) => Promise.race([
|
||||
readiness.promise,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
const cancel = () => reject(signal?.reason)
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
if (signal?.aborted) cancel()
|
||||
}),
|
||||
])
|
||||
}
|
||||
return { manager, runtime, readiness, started, stopped }
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +106,49 @@ async function createReady(harness: ReturnType<typeof createHarness>) {
|
|||
}
|
||||
|
||||
describe("workspace manager lifecycle", () => {
|
||||
it("rejects a healthy workspace whose OpenCode configuration is invalid", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const requests: string[] = []
|
||||
const configError = JSON.stringify({
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
|
||||
issues: [{ path: ["tools", "bash"], message: 'Expected boolean, got "ask"' }],
|
||||
},
|
||||
})
|
||||
globalThis.fetch = (async (input: URL | RequestInfo) => {
|
||||
const url = String(input)
|
||||
requests.push(url)
|
||||
if (url.includes("/global/health")) {
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.18.5" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
return new Response(configError, { status: 400, headers: { "Content-Type": "application/json" } })
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const harness = createHarness({ stubReadiness: false })
|
||||
;(harness.manager as any).waitForPortAvailability = async () => undefined
|
||||
const creation = harness.manager.create(process.cwd())
|
||||
const workspaceId = await harness.runtime.launchCalled.promise
|
||||
harness.runtime.resolveLaunch()
|
||||
|
||||
await assert.rejects(creation, (error: unknown) => {
|
||||
assert.ok(error instanceof Error)
|
||||
assert.equal(error.message, configError)
|
||||
return true
|
||||
})
|
||||
assert.deepEqual(requests.map((url) => new URL(url).pathname), ["/global/health", "/config"])
|
||||
assert.equal(new URL(requests[1]).search, "")
|
||||
assert.equal(harness.runtime.active.has(workspaceId), false)
|
||||
assert.deepEqual(harness.started, [])
|
||||
assert.deepEqual(harness.manager.list(), [])
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
for (const boundary of ["launch", "readiness", "shutdown"] as const) {
|
||||
it(`cancels and cleans a workspace during ${boundary}`, async () => {
|
||||
const harness = createHarness()
|
||||
|
|
|
|||
|
|
@ -713,6 +713,11 @@ export class WorkspaceManager {
|
|||
|
||||
const version = await this.waitForInstanceHealth(params)
|
||||
|
||||
await Promise.race([
|
||||
this.validateInstanceConfiguration(params),
|
||||
this.exitDuringStartup(params, "exited during configuration validation"),
|
||||
])
|
||||
|
||||
await Promise.race([
|
||||
delay(STARTUP_STABILITY_DELAY_MS, undefined, { signal: params.signal }),
|
||||
this.exitDuringStartup(params, "exited shortly after start"),
|
||||
|
|
@ -753,13 +758,7 @@ export class WorkspaceManager {
|
|||
const url = `http://${LOOPBACK_HOST}:${port}/global/health`
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {}
|
||||
const authHeader = this.opencodeAuth.get(workspaceId)?.authorization
|
||||
if (authHeader) {
|
||||
headers["Authorization"] = authHeader
|
||||
}
|
||||
|
||||
const response = await fetch(url, { headers, signal })
|
||||
const response = await fetch(url, { headers: this.getInstanceRequestHeaders(workspaceId), signal })
|
||||
if (!response.ok) {
|
||||
const reason = `/global/health returned HTTP ${response.status}`
|
||||
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
|
||||
|
|
@ -784,6 +783,25 @@ export class WorkspaceManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async validateInstanceConfiguration(params: WorkspaceReadiness): Promise<void> {
|
||||
const response = await fetch(`http://${LOOPBACK_HOST}:${params.port}/config`, {
|
||||
headers: this.getInstanceRequestHeaders(params.workspaceId),
|
||||
signal: params.signal,
|
||||
})
|
||||
if (response.ok) {
|
||||
await response.body?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
const body = (await response.text()).trim()
|
||||
throw new Error(body || `OpenCode /config returned HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
private getInstanceRequestHeaders(workspaceId: string): Record<string, string> {
|
||||
const authorization = this.opencodeAuth.get(workspaceId)?.authorization
|
||||
return authorization ? { Authorization: authorization } : {}
|
||||
}
|
||||
|
||||
private buildStartupError(
|
||||
workspaceId: string,
|
||||
phase: string,
|
||||
|
|
|
|||
|
|
@ -303,7 +303,11 @@ const App: Component = () => {
|
|||
port: instances().get(result.instanceId)?.port,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
|
||||
const message = formatLaunchErrorMessage(
|
||||
error,
|
||||
t("app.launchError.fallbackMessage"),
|
||||
t("app.launchError.invalidConfig"),
|
||||
)
|
||||
const missingBinary = isMissingBinaryMessage(message)
|
||||
showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
|
||||
log.error("Failed to create instance", error)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "OpenCode konnte nicht gestartet werden",
|
||||
"app.launchError.description": "Die ausgewählte OpenCode-Binärdatei konnte nicht gestartet werden. Überprüfen Sie die Fehlerausgabe unten oder wählen Sie eine andere Binärdatei in den OpenCode-Einstellungen.",
|
||||
"app.launchError.description": "OpenCode konnte diesen Arbeitsbereich nicht starten. Überprüfen Sie die Fehlerausgabe unten oder Ihre OpenCode-Einstellungen.",
|
||||
"app.launchError.binaryPathLabel": "Binärpfad",
|
||||
"app.launchError.errorOutputLabel": "Fehlerausgabe",
|
||||
"app.launchError.openAdvancedSettings": "OpenCode-Einstellungen öffnen",
|
||||
"app.launchError.close": "Schließen",
|
||||
"app.launchError.closeTitle": "Schließen (Esc)",
|
||||
"app.launchError.fallbackMessage": "Arbeitsbereich konnte nicht gestartet werden",
|
||||
"app.launchError.invalidConfig": "Die OpenCode-Konfiguration ist ungültig",
|
||||
|
||||
"app.stopInstance.confirmMessage": "OpenCode-Instanz stoppen? Dies wird den Server beenden.",
|
||||
"app.stopInstance.title": "Instanz stoppen",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "Unable to launch OpenCode",
|
||||
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.",
|
||||
"app.launchError.description": "OpenCode could not start this workspace. Review the error output below or check your OpenCode settings.",
|
||||
"app.launchError.binaryPathLabel": "Binary path",
|
||||
"app.launchError.errorOutputLabel": "Error output",
|
||||
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
|
||||
"app.launchError.close": "Close",
|
||||
"app.launchError.closeTitle": "Close (Esc)",
|
||||
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
||||
"app.launchError.invalidConfig": "OpenCode configuration is invalid",
|
||||
|
||||
"app.stopInstance.confirmMessage": "Stop OpenCode instance? This will stop the server.",
|
||||
"app.stopInstance.title": "Stop instance",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "No se pudo iniciar OpenCode",
|
||||
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.",
|
||||
"app.launchError.description": "OpenCode no pudo iniciar este espacio de trabajo. Revisa la salida de error o la configuración de OpenCode.",
|
||||
"app.launchError.binaryPathLabel": "Ruta del binario",
|
||||
"app.launchError.errorOutputLabel": "Salida de error",
|
||||
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
|
||||
"app.launchError.close": "Cerrar",
|
||||
"app.launchError.closeTitle": "Cerrar (Esc)",
|
||||
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
|
||||
"app.launchError.invalidConfig": "La configuración de OpenCode no es válida",
|
||||
|
||||
"app.stopInstance.confirmMessage": "¿Detener la instancia de OpenCode? Esto detendrá el servidor.",
|
||||
"app.stopInstance.title": "Detener instancia",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "Impossible de lancer OpenCode",
|
||||
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.",
|
||||
"app.launchError.description": "OpenCode n'a pas pu démarrer cet espace de travail. Consultez la sortie d'erreur ci-dessous ou vérifiez vos paramètres OpenCode.",
|
||||
"app.launchError.binaryPathLabel": "Chemin du binaire",
|
||||
"app.launchError.errorOutputLabel": "Sortie d'erreur",
|
||||
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
|
||||
"app.launchError.close": "Fermer",
|
||||
"app.launchError.closeTitle": "Fermer (Esc)",
|
||||
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
|
||||
"app.launchError.invalidConfig": "La configuration OpenCode n'est pas valide",
|
||||
|
||||
"app.stopInstance.confirmMessage": "Arrêter l'instance OpenCode ? Cela arrêtera le serveur.",
|
||||
"app.stopInstance.title": "Arrêter l'instance",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "לא ניתן להפעיל את OpenCode",
|
||||
"app.launchError.description": "לא הצלחנו להפעיל את קובץ ה-OpenCode שנבחר. בדוק את פלט השגיאה למטה או בחר קובץ בינארי אחר מהגדרות OpenCode.",
|
||||
"app.launchError.description": "OpenCode לא הצליח להפעיל את סביבת העבודה הזו. בדוק את פלט השגיאה למטה או את הגדרות OpenCode.",
|
||||
"app.launchError.binaryPathLabel": "נתיב הקובץ הבינארי",
|
||||
"app.launchError.errorOutputLabel": "פלט שגיאה",
|
||||
"app.launchError.openAdvancedSettings": "פתח הגדרות OpenCode",
|
||||
"app.launchError.close": "סגור",
|
||||
"app.launchError.closeTitle": "סגור (Esc)",
|
||||
"app.launchError.fallbackMessage": "הפעלת סביבת העבודה נכשלה",
|
||||
"app.launchError.invalidConfig": "תצורת OpenCode אינה תקינה",
|
||||
|
||||
"app.stopInstance.confirmMessage": "לעצור את מופע OpenCode? פעולה זו תעצור את השרת.",
|
||||
"app.stopInstance.title": "עצור מופע",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "OpenCode を起動できません",
|
||||
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
|
||||
"app.launchError.description": "OpenCode はこのワークスペースを起動できませんでした。下のエラー出力または OpenCode 設定を確認してください。",
|
||||
"app.launchError.binaryPathLabel": "バイナリのパス",
|
||||
"app.launchError.errorOutputLabel": "エラー出力",
|
||||
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
|
||||
"app.launchError.close": "閉じる",
|
||||
"app.launchError.closeTitle": "閉じる (Esc)",
|
||||
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
|
||||
"app.launchError.invalidConfig": "OpenCode の設定が無効です",
|
||||
|
||||
"app.stopInstance.confirmMessage": "OpenCode インスタンスを停止しますか?サーバーが停止します。",
|
||||
"app.stopInstance.title": "インスタンスを停止",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "OpenCode सुरु गर्न असमर्थ",
|
||||
"app.launchError.description": "हामीले चयन गरिएको OpenCode बाइनरी सुरु गर्न सकेनौं। तलको त्रुटि आउटपुट समीक्षा गर्नुहोस् वा OpenCode सेटिङहरूबाट फरक बाइनरी छनौट गर्नुहोस्।",
|
||||
"app.launchError.description": "OpenCode ले यो कार्यस्थान सुरु गर्न सकेन। तलको त्रुटि आउटपुट वा OpenCode सेटिङहरू जाँच गर्नुहोस्।",
|
||||
"app.launchError.binaryPathLabel": "बाइनरी मार्ग (Binary path)",
|
||||
"app.launchError.errorOutputLabel": "त्रुटि आउटपुट (Error output)",
|
||||
"app.launchError.openAdvancedSettings": "OpenCode सेटिङहरू खोल्नुहोस्",
|
||||
"app.launchError.close": "बन्द गर्नुहोस्",
|
||||
"app.launchError.closeTitle": "बन्द गर्नुहोस् (Esc)",
|
||||
"app.launchError.fallbackMessage": "कार्यस्थान सुरु गर्न असफल",
|
||||
"app.launchError.invalidConfig": "OpenCode कन्फिगरेसन अमान्य छ",
|
||||
|
||||
"app.stopInstance.confirmMessage": "OpenCode उदाहरण रोक्ने? यसले सर्भर बन्द गर्नेछ।",
|
||||
"app.stopInstance.title": "उदाहरण रोक्नुहोस्",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "Не удалось запустить OpenCode",
|
||||
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
|
||||
"app.launchError.description": "OpenCode не удалось запустить это рабочее пространство. Проверьте вывод ошибки ниже или настройки OpenCode.",
|
||||
"app.launchError.binaryPathLabel": "Путь к бинарнику",
|
||||
"app.launchError.errorOutputLabel": "Вывод ошибки",
|
||||
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
|
||||
"app.launchError.close": "Закрыть",
|
||||
"app.launchError.closeTitle": "Закрыть (Esc)",
|
||||
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
|
||||
"app.launchError.invalidConfig": "Конфигурация OpenCode недействительна",
|
||||
|
||||
"app.stopInstance.confirmMessage": "Остановить экземпляр OpenCode? Это остановит сервер.",
|
||||
"app.stopInstance.title": "Остановить экземпляр",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
export const appMessages = {
|
||||
"app.launchError.title": "无法启动 OpenCode",
|
||||
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。",
|
||||
"app.launchError.description": "OpenCode 无法启动此工作区。请查看下面的错误输出或检查 OpenCode 设置。",
|
||||
"app.launchError.binaryPathLabel": "可执行文件路径",
|
||||
"app.launchError.errorOutputLabel": "错误输出",
|
||||
"app.launchError.openAdvancedSettings": "打开 OpenCode 设置",
|
||||
"app.launchError.close": "关闭",
|
||||
"app.launchError.closeTitle": "关闭 (Esc)",
|
||||
"app.launchError.fallbackMessage": "启动工作区失败",
|
||||
"app.launchError.invalidConfig": "OpenCode 配置无效",
|
||||
|
||||
"app.stopInstance.confirmMessage": "停止 OpenCode 实例?这将停止服务器。",
|
||||
"app.stopInstance.title": "停止实例",
|
||||
|
|
|
|||
52
packages/ui/src/lib/launch-errors.test.ts
Normal file
52
packages/ui/src/lib/launch-errors.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { formatLaunchErrorMessage } from "./launch-errors"
|
||||
|
||||
describe("formatLaunchErrorMessage", () => {
|
||||
it("formats OpenCode configuration validation details", () => {
|
||||
const error = new Error(JSON.stringify({
|
||||
name: "ConfigInvalidError",
|
||||
data: {
|
||||
path: "C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
|
||||
issues: [
|
||||
{ path: ["tools", "bash"], message: 'Expected boolean, got "ask"' },
|
||||
{ path: ["tools", "webfetch"], message: 'Expected boolean, got "ask"' },
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
assert.equal(formatLaunchErrorMessage(error, "fallback", "OpenCode configuration is invalid"), [
|
||||
"OpenCode configuration is invalid",
|
||||
"C:\\Users\\dev\\.config\\opencode\\agents\\invalid.md",
|
||||
'tools.bash: Expected boolean, got "ask"',
|
||||
'tools.webfetch: Expected boolean, got "ask"',
|
||||
].join("\n"))
|
||||
})
|
||||
|
||||
it("preserves message-only tagged configuration errors", () => {
|
||||
const error = JSON.stringify({
|
||||
_tag: "ConfigInvalidError",
|
||||
path: "/home/dev/.config/opencode/opencode.json",
|
||||
message: "Missing environment variable",
|
||||
})
|
||||
|
||||
assert.equal(formatLaunchErrorMessage(error, "fallback", "Invalid configuration"), [
|
||||
"Invalid configuration",
|
||||
"/home/dev/.config/opencode/opencode.json",
|
||||
"Missing environment variable",
|
||||
].join("\n"))
|
||||
})
|
||||
|
||||
it("preserves configuration directory typo suggestions", () => {
|
||||
const error = JSON.stringify({
|
||||
name: "ConfigDirectoryTypoError",
|
||||
data: { dir: "/project/.opencod", suggestion: "/project/.opencode" },
|
||||
})
|
||||
|
||||
assert.equal(formatLaunchErrorMessage(error, "fallback", "Invalid configuration"), [
|
||||
"Invalid configuration",
|
||||
"/project/.opencod → /project/.opencode",
|
||||
].join("\n"))
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string {
|
||||
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string, invalidConfigMessage: string): string {
|
||||
if (!error) {
|
||||
return fallbackMessage
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string
|
|||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
const configError = formatConfigError(parsed, invalidConfigMessage)
|
||||
if (configError) return configError
|
||||
if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") {
|
||||
return (parsed as any).error
|
||||
}
|
||||
|
|
@ -17,6 +19,41 @@ export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string
|
|||
return raw
|
||||
}
|
||||
|
||||
function formatConfigError(value: unknown, invalidConfigMessage: string): string | undefined {
|
||||
if (!value || typeof value !== "object") return undefined
|
||||
type ConfigErrorDetails = {
|
||||
path?: unknown
|
||||
message?: unknown
|
||||
issues?: unknown
|
||||
dir?: unknown
|
||||
suggestion?: unknown
|
||||
}
|
||||
const error = value as ConfigErrorDetails & { name?: unknown; _tag?: unknown; data?: ConfigErrorDetails }
|
||||
const name = typeof error.name === "string" ? error.name : error._tag
|
||||
if (!["ConfigInvalidError", "ConfigJsonError", "ConfigFrontmatterError", "ConfigDirectoryTypoError"].includes(String(name))) return undefined
|
||||
const details = error.data && typeof error.data === "object" ? error.data : error
|
||||
|
||||
const lines = [invalidConfigMessage]
|
||||
if (typeof details.path === "string" && details.path.trim()) lines.push(details.path.trim())
|
||||
if (typeof details.message === "string" && details.message.trim()) lines.push(details.message.trim())
|
||||
const dir = typeof details.dir === "string" ? details.dir.trim() : ""
|
||||
const suggestion = typeof details.suggestion === "string" ? details.suggestion.trim() : ""
|
||||
if (dir && suggestion) lines.push(`${dir} → ${suggestion}`)
|
||||
else if (dir || suggestion) lines.push(dir || suggestion)
|
||||
if (Array.isArray(details.issues)) {
|
||||
for (const issue of details.issues) {
|
||||
if (!issue || typeof issue !== "object") continue
|
||||
const candidate = issue as { path?: unknown; message?: unknown }
|
||||
const location = Array.isArray(candidate.path) ? candidate.path.map(String).join(".") : ""
|
||||
const message = typeof candidate.message === "string" ? candidate.message.trim() : ""
|
||||
if (location && message) lines.push(`${location}: ${message}`)
|
||||
else if (message) lines.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function isMissingBinaryMessage(message: string): boolean {
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -29,7 +29,11 @@ export function clearLaunchError() {
|
|||
export function showWorkspaceLaunchError(workspace: WorkspaceDescriptor) {
|
||||
const instanceId = workspace.id
|
||||
const rawMessage = workspace.error
|
||||
const message = formatLaunchErrorMessage(rawMessage, tGlobal("app.launchError.fallbackMessage"))
|
||||
const message = formatLaunchErrorMessage(
|
||||
rawMessage,
|
||||
tGlobal("app.launchError.fallbackMessage"),
|
||||
tGlobal("app.launchError.invalidConfig"),
|
||||
)
|
||||
|
||||
const previous = lastWorkspaceErrorByInstanceId.get(instanceId)
|
||||
if (previous && previous === message) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue