From a3f4b1a89257e82dfa236166a90e132fb9c59ab7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 11 Jun 2026 21:43:45 -0700 Subject: [PATCH 1/3] Add Reset data recovery for damaged executor state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed migration or corrupt SQLite makes the sidecar fail to start forever — updating can't fix it. Reset moves the data dir aside (never deletes) into ~/.executor/backups// and starts fresh, so the user's connections and secrets stay recoverable. - resetExecutorState() backs up data.db (+ wal/shm) and server-control - Offered two ways: a 'Reset data' link on the in-window crash screen (for crashes under a live window) and a 'Reset data and retry…' button on the fatal-startup dialog (for boot failures), each behind a native confirm - New e2e scenario: corrupts data.db, confirms restart alone fails, then resets and asserts the app heals AND the corrupted db is preserved in the backup dir --- apps/desktop/src/main/crash-screen.ts | 19 ++++ apps/desktop/src/main/index.ts | 47 ++++++++-- apps/desktop/src/main/reset-state.ts | 78 ++++++++++++++++ apps/desktop/src/preload/index.ts | 8 ++ e2e/desktop/reset-state.test.ts | 123 ++++++++++++++++++++++++++ 5 files changed, 267 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/main/reset-state.ts create mode 100644 e2e/desktop/reset-state.test.ts diff --git a/apps/desktop/src/main/crash-screen.ts b/apps/desktop/src/main/crash-screen.ts index a208e1876..ec4420af2 100644 --- a/apps/desktop/src/main/crash-screen.ts +++ b/apps/desktop/src/main/crash-screen.ts @@ -45,6 +45,8 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `< } button.secondary { background: transparent; color: #fafafa; border-color: #3f3f46; } #status { margin-top: 1.25rem; min-height: 1.2em; font-size: 0.8rem; color: #a1a1aa; } + .reset-row { margin-top: 1.75rem; font-size: 0.75rem; color: #71717a; } + .reset-row a { color: #f87171; text-decoration: underline; cursor: pointer; } @@ -61,6 +63,11 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<

+

+ Keeps crashing? + Reset data + — your current data is backed up first, never deleted. +

`; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9c91efa01..af4716da6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -33,6 +33,7 @@ import { reportAProblem, } from "./diagnostics"; import { sidecarCrashHtml } from "./crash-screen"; +import { confirmResetState, resetExecutorState } from "./reset-state"; import { getServerProfiles, getServerSettings, @@ -333,6 +334,19 @@ const registerIpcHandlers = () => { // fixed upstream. Reuses the menu flow — staged updates prompt to install, // "no updates" / failures surface in their own dialogs. ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true })); + // Crash-screen last resort for damaged state: confirm, move the data dir + // aside (never delete), then restart the sidecar against the fresh dir. + // Returns false when the user cancelled. + ipcMain.handle("executor:state:reset", async (): Promise => { + if (!(await confirmResetState())) return false; + if (connection) { + await stopSidecar(connection.child); + connection = null; + } + resetExecutorState(); + await restartSidecarAndReload(); + return true; + }); ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => { if (typeof rawUrl !== "string") return; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input @@ -475,13 +489,23 @@ const handleFatalSidecarFailure = async (error: unknown) => { } // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: sidecar startup failures arrive as plain Node errors and render in a native dialog const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); - await dialog.showMessageBox({ + const { response } = await dialog.showMessageBox({ type: "error", title: "Executor failed to start", message: "The local Executor server crashed during startup.", detail: `${detail.slice(0, 1800)}\n\nFull log: ${log.transports.file.getFile().path}`, - buttons: ["Quit"], + buttons: ["Quit", "Reset data and retry…"], + defaultId: 0, + cancelId: 0, }); + // Damaged executor state (failed migration, corrupt SQLite) makes startup + // fail forever — updating can't fix it. Offer the move-aside reset and one + // immediate retry. Returns true when boot should be attempted again. + if (response === 1 && (await confirmResetState())) { + resetExecutorState(); + return true; + } + return false; }; const installApplicationMenu = () => { @@ -545,15 +569,22 @@ const boot = async () => { void runUpdateCheck({ alertOnFail: false }); }); connection = await startWithCurrentSettings(); - if (!connection) { + if (!connection && lastSidecarStartError != null) { // Port conflicts already showed their dialog inside // startWithCurrentSettings; every other failure surfaces here so the app - // never silently bounces-and-vanishes. Pointing a window at the - // (unreachable) baseUrl would just show ECONNREFUSED — a placeholder URL - // would be worse. For now: explain, offer the updater a chance, quit. - if (lastSidecarStartError != null) { - await handleFatalSidecarFailure(lastSidecarStartError); + // never silently bounces-and-vanishes. The dialog offers a data reset + // (move-aside, for damaged state) — when taken, retry the boot once + // against the fresh dir. + const retryAfterReset = await handleFatalSidecarFailure(lastSidecarStartError); + if (retryAfterReset) { + lastSidecarStartError = null; + connection = await startWithCurrentSettings(); + if (!connection && lastSidecarStartError != null) { + await handleFatalSidecarFailure(lastSidecarStartError); + } } + } + if (!connection) { app.quit(); return; } diff --git a/apps/desktop/src/main/reset-state.ts b/apps/desktop/src/main/reset-state.ts new file mode 100644 index 000000000..a6daa9dc5 --- /dev/null +++ b/apps/desktop/src/main/reset-state.ts @@ -0,0 +1,78 @@ +/** + * Last-resort recovery for a data dir the sidecar can no longer open + * (failed migration, corrupted SQLite, …): move the executor state aside + * and start fresh. + * + * Strictly backup-then-move — nothing is ever deleted. data.db holds the + * user's connections and secrets, so the old state lands in a timestamped + * folder under ~/.executor/backups/ where it can be restored by copying + * the files back. + * + * Scope: only the sidecar-owned state (data.db + SQLite sidecar files and + * server-control/). The plugin manifest (executor.jsonc) is user-authored + * config, not state — a reset shouldn't discard hand-written setup — and + * desktop settings (port/auth) live in Electron's own store, unaffected. + */ + +import { existsSync, mkdirSync, renameSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { dialog } from "electron"; +import log from "electron-log/main.js"; + +const STATE_ENTRIES = ["data.db", "data.db-wal", "data.db-shm", "server-control"]; + +const backupStamp = () => + new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d+Z$/, "Z"); + +export interface ResetStateResult { + readonly backupDir: string; + readonly moved: ReadonlyArray; +} + +/** + * Move the executor state into ~/.executor/backups//. The caller is + * responsible for stopping the sidecar first and restarting it after. + */ +export const resetExecutorState = (): ResetStateResult => { + const dataDir = join(homedir(), ".executor"); + const backupDir = join(dataDir, "backups", backupStamp()); + mkdirSync(backupDir, { recursive: true }); + const moved: string[] = []; + for (const entry of STATE_ENTRIES) { + const from = join(dataDir, entry); + if (!existsSync(from)) continue; + renameSync(from, join(backupDir, entry)); + moved.push(entry); + } + log.info("[reset-state] moved executor state to backup", { backupDir, moved }); + return { backupDir, moved }; +}; + +/** + * Confirmation dialog shared by every surface that offers a reset. Returns + * true when the user explicitly chose to reset. + * + * EXECUTOR_TEST_AUTO_CONFIRM_RESET=1 skips the dialog — native dialogs are + * unreachable from Playwright, and the e2e crash-recovery scenario needs to + * drive the full reset path. + */ +export const confirmResetState = async (): Promise => { + if (process.env.EXECUTOR_TEST_AUTO_CONFIRM_RESET === "1") return true; + const { response } = await dialog.showMessageBox({ + type: "warning", + title: "Reset Executor data?", + message: "Start over with a fresh data directory?", + detail: + "Your current data — integrations, connections, and history — will be moved to " + + "~/.executor/backups (not deleted), and Executor will restart with a clean slate. " + + "Use this when the app can't start because its data is damaged.", + buttons: ["Reset and back up", "Cancel"], + defaultId: 1, + cancelId: 1, + }); + return response === 0; +}; diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 85ccc551c..ba36ff401 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -55,6 +55,14 @@ const api = { checkForUpdates(): Promise { return ipcRenderer.invoke("executor:updates:check"); }, + /** + * Last-resort recovery for damaged executor state: after a native confirm, + * back up the data dir (move-aside, never delete), then restart the + * sidecar fresh. Resolves false when the user cancels the confirm. + */ + resetState(): Promise { + return ipcRenderer.invoke("executor:state:reset"); + }, /** * Crash-reporting config for the renderer. Null unless this desktop build * shipped with a DSN baked in — the shared web UI only initializes its diff --git a/e2e/desktop/reset-state.test.ts b/e2e/desktop/reset-state.test.ts new file mode 100644 index 000000000..534b28401 --- /dev/null +++ b/e2e/desktop/reset-state.test.ts @@ -0,0 +1,123 @@ +// Desktop-only: the damaged-data recovery path, on camera. Boots the app +// once to create real state, kills the sidecar and corrupts data.db so a +// restart cannot succeed, then recovers via the crash screen's "Reset data" +// link (EXECUTOR_TEST_AUTO_CONFIRM_RESET=1 stands in for the native confirm +// dialog, which Playwright can't reach). Asserts the reset is backup-then- +// move: the corrupted bytes must be in ~/.executor/backups/, not gone. +import { execFile } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { _electron } from "playwright"; + +import { scenario } from "../src/scenario"; +import { RunDir } from "../src/services"; + +const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); +const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; + +const CORRUPT_MARKER = "executor-e2e-corrupted-db"; + +scenario( + "Desktop · reset data recovers from a damaged database, backing it up first", + { timeout: 300_000 }, + Effect.gen(function* () { + const runDir = yield* RunDir; + yield* Effect.promise(() => run(runDir)); + }), +); + +const run = async (runDir: string) => { + const home = mkdtempSync(join(tmpdir(), "executor-desktop-e2e-reset-")); + const videoTmp = join(runDir, ".video-tmp"); + let stepIndex = 0; + + const app = await _electron.launch({ + executablePath: electronBinary, + args: [appDir], + cwd: appDir, + env: { ...process.env, HOME: home, EXECUTOR_TEST_AUTO_CONFIRM_RESET: "1" }, + recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, + timeout: 120_000, + }); + + try { + const page = await app.firstWindow({ timeout: 120_000 }); + const step = async (label: string, body: () => Promise) => { + await body(); + stepIndex += 1; + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + await page.screenshot({ + path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), + }); + }; + + await step("app boots into the web console", async () => { + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + }); + + await step("the database is corrupted and the server killed", async () => { + const manifest = JSON.parse( + readFileSync(join(home, ".executor/server-control/server.json"), "utf8"), + ) as { pid: number }; + // Corrupt first so the sidecar can never come back on its own, then + // kill it to bring up the crash screen. + writeFileSync(join(home, ".executor/data.db"), CORRUPT_MARKER); + rmSync(join(home, ".executor/data.db-wal"), { force: true }); + rmSync(join(home, ".executor/data.db-shm"), { force: true }); + process.kill(manifest.pid, "SIGKILL"); + await page.getByText("stopped unexpectedly").waitFor({ timeout: 30_000 }); + }); + + await step("restart alone cannot heal a damaged database", async () => { + await page.locator("#restart").click(); + await page.getByText("Restart failed").waitFor({ timeout: 60_000 }); + }); + + await step("reset data backs the state up and heals the app", async () => { + await page.locator("#reset").click(); + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); + }); + + // Backup-then-move, never delete: the corrupted bytes must live on in + // ~/.executor/backups//data.db, and the live db must be fresh. + const backupsDir = join(home, ".executor/backups"); + const stamps = readdirSync(backupsDir); + expect(stamps.length, "exactly one backup created").toBe(1); + const backedUp = readFileSync(join(backupsDir, stamps[0] ?? "", "data.db"), "utf8"); + expect(backedUp, "backup holds the pre-reset (corrupted) database").toBe(CORRUPT_MARKER); + const liveDb = readFileSync(join(home, ".executor/data.db")); + expect(liveDb.subarray(0, 6).toString(), "live database is a real SQLite file").toBe("SQLite"); + } finally { + const page = app.windows()[0]; + const video = page?.video(); + await app.close().catch(() => {}); + const recordedPath = await video?.path().catch(() => undefined); + if (recordedPath && existsSync(recordedPath)) { + await promisify(execFile)("ffmpeg", [ + "-y", + "-i", + recordedPath, + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "26", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + join(runDir, "session.mp4"), + ]).catch(() => {}); + } + rmSync(videoTmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } +}; From dca153abfbeb24481356fd82c0dbb9a1e4981fa3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 11 Jun 2026 21:44:19 -0700 Subject: [PATCH 2/3] Add desktop crash issue template --- .github/ISSUE_TEMPLATE/desktop-crash.yml | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/desktop-crash.yml diff --git a/.github/ISSUE_TEMPLATE/desktop-crash.yml b/.github/ISSUE_TEMPLATE/desktop-crash.yml new file mode 100644 index 000000000..8c138eb8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/desktop-crash.yml @@ -0,0 +1,69 @@ +name: Desktop app crash +description: The Executor desktop app crashes, won't start, or shows a blank window. +title: "[desktop] " +labels: ["desktop", "crash"] +body: + - type: markdown + attributes: + value: | + Before filing, please try the quick fixes in the pinned + **[Desktop app crashing? Start here](https://github.com/RhysSullivan/executor/issues?q=is%3Aissue+label%3Acrash-triage)** + guide — most crashes are fixed by updating to the latest version. + If you're still stuck, the details below help us pin it down fast. + - type: input + id: version + attributes: + label: Executor version + description: Shown in the About menu (or Settings page). Please update to the latest first if you can. + placeholder: "1.5.4" + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS (Apple Silicon) + - macOS (Intel) + - Windows + - Linux + validations: + required: true + - type: dropdown + id: when + attributes: + label: When does it crash? + options: + - On launch — the app never opens + - Shortly after launch + - While doing something specific (describe below) + - Randomly during use + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened + description: What were you doing when it crashed? Does it happen every time? + validations: + required: true + - type: textarea + id: logs + attributes: + label: Diagnostics / logs + description: | + If the app opens far enough to show a menu, use **Help → Export Diagnostics…** + (or **Report a Problem…**) and attach the zip — it has everything we need and no secrets. + + Otherwise, attach the log file directly: + - macOS: `~/Library/Logs/Executor/main.log` + - Windows: `%APPDATA%\Executor\logs\main.log` + - Linux: `~/.config/Executor/logs/main.log` + + (Please don't attach anything from `~/.executor` — that's where your data and secrets live.) + placeholder: Drag the diagnostics zip or main.log here. + - type: input + id: run-id + attributes: + label: Run ID (optional) + description: If a crash report was sent, the Run ID from the diagnostics manifest helps us find it. From a13587fad0e9a1ef2f6128427512416e1ef06367 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 11 Jun 2026 21:52:51 -0700 Subject: [PATCH 3/3] Make the reset backup actionable instead of just reassuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crash-screen reset copy now says what reset is FOR (clears a damaged database) rather than dwelling on the backup. After a reset completes, an info dialog names the exact backup folder and offers Show in folder — so 'your data is backed up' is something the user can act on, not just a promise. --- apps/desktop/src/main/crash-screen.ts | 4 ++-- apps/desktop/src/main/index.ts | 8 +++++--- apps/desktop/src/main/reset-state.ts | 28 ++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/crash-screen.ts b/apps/desktop/src/main/crash-screen.ts index ec4420af2..2ca57d5a1 100644 --- a/apps/desktop/src/main/crash-screen.ts +++ b/apps/desktop/src/main/crash-screen.ts @@ -64,9 +64,9 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<

- Keeps crashing? + Still won't start after restarting? Reset data - — your current data is backed up first, never deleted. + clears a damaged database to get the app running again.