diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 28e5f98d..02ccb32c 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -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 diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 7b9a66e4..e7f6061a 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -59,11 +59,13 @@ class ControlledRuntime { } function createHarness(options: { + stubReadiness?: boolean shutdownTimeoutMs?: number launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ReturnType clearTimeout?: (timer: ReturnType) => void } = {}) { + const { stubReadiness = true, ...managerOptions } = options const eventBus = new EventBus() const runtime = new ControlledRuntime() const readiness = deferred() @@ -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((_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((_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) { } 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() diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 6966f705..6d39c919 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -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 = {} - 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 { + 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 { + const authorization = this.opencodeAuth.get(workspaceId)?.authorization + return authorization ? { Authorization: authorization } : {} + } + private buildStartupError( workspaceId: string, phase: string, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 189957c7..04f28790 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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) diff --git a/packages/ui/src/lib/i18n/messages/de/app.ts b/packages/ui/src/lib/i18n/messages/de/app.ts index d01d7689..8472d652 100644 --- a/packages/ui/src/lib/i18n/messages/de/app.ts +++ b/packages/ui/src/lib/i18n/messages/de/app.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/en/app.ts b/packages/ui/src/lib/i18n/messages/en/app.ts index 1fd95247..edcaf57d 100644 --- a/packages/ui/src/lib/i18n/messages/en/app.ts +++ b/packages/ui/src/lib/i18n/messages/en/app.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/es/app.ts b/packages/ui/src/lib/i18n/messages/es/app.ts index 95dfde99..99ce5f67 100644 --- a/packages/ui/src/lib/i18n/messages/es/app.ts +++ b/packages/ui/src/lib/i18n/messages/es/app.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/fr/app.ts b/packages/ui/src/lib/i18n/messages/fr/app.ts index b2d0fd31..e5c9ae5c 100644 --- a/packages/ui/src/lib/i18n/messages/fr/app.ts +++ b/packages/ui/src/lib/i18n/messages/fr/app.ts @@ -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", diff --git a/packages/ui/src/lib/i18n/messages/he/app.ts b/packages/ui/src/lib/i18n/messages/he/app.ts index e00b6963..4a6501e7 100644 --- a/packages/ui/src/lib/i18n/messages/he/app.ts +++ b/packages/ui/src/lib/i18n/messages/he/app.ts @@ -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": "עצור מופע", diff --git a/packages/ui/src/lib/i18n/messages/ja/app.ts b/packages/ui/src/lib/i18n/messages/ja/app.ts index e0b24dca..ece3e28b 100644 --- a/packages/ui/src/lib/i18n/messages/ja/app.ts +++ b/packages/ui/src/lib/i18n/messages/ja/app.ts @@ -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": "インスタンスを停止", diff --git a/packages/ui/src/lib/i18n/messages/ne/app.ts b/packages/ui/src/lib/i18n/messages/ne/app.ts index 9bcf7ace..ce78b306 100644 --- a/packages/ui/src/lib/i18n/messages/ne/app.ts +++ b/packages/ui/src/lib/i18n/messages/ne/app.ts @@ -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": "उदाहरण रोक्नुहोस्", diff --git a/packages/ui/src/lib/i18n/messages/ru/app.ts b/packages/ui/src/lib/i18n/messages/ru/app.ts index 3fefb3f7..e14a60fd 100644 --- a/packages/ui/src/lib/i18n/messages/ru/app.ts +++ b/packages/ui/src/lib/i18n/messages/ru/app.ts @@ -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": "Остановить экземпляр", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts index 0cc9d917..3a502e47 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/app.ts @@ -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": "停止实例", diff --git a/packages/ui/src/lib/launch-errors.test.ts b/packages/ui/src/lib/launch-errors.test.ts new file mode 100644 index 00000000..f11bb7d4 --- /dev/null +++ b/packages/ui/src/lib/launch-errors.test.ts @@ -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")) + }) +}) diff --git a/packages/ui/src/lib/launch-errors.ts b/packages/ui/src/lib/launch-errors.ts index 0d495f97..9397c074 100644 --- a/packages/ui/src/lib/launch-errors.ts +++ b/packages/ui/src/lib/launch-errors.ts @@ -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 ( diff --git a/packages/ui/src/stores/launch-errors.ts b/packages/ui/src/stores/launch-errors.ts index 814790f8..3513aa0b 100644 --- a/packages/ui/src/stores/launch-errors.ts +++ b/packages/ui/src/stores/launch-errors.ts @@ -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) {