Skip to content

Commit 815a0df

Browse files
authored
Desktop: Reset data recovery + crash issue template (#965)
* Add Reset data recovery for damaged executor state 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/<stamp>/ 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 * Add desktop crash issue template * Make the reset backup actionable instead of just reassuring 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.
1 parent 5c715b5 commit 815a0df

6 files changed

Lines changed: 364 additions & 8 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
name: Desktop app crash
2+
description: The Executor desktop app crashes, won't start, or shows a blank window.
3+
title: "[desktop] "
4+
labels: ["desktop", "crash"]
5+
body:
6+
- type: markdown
7+
attributes:
8+
value: |
9+
Before filing, please try the quick fixes in the pinned
10+
**[Desktop app crashing? Start here](https://github.com/RhysSullivan/executor/issues?q=is%3Aissue+label%3Acrash-triage)**
11+
guide — most crashes are fixed by updating to the latest version.
12+
If you're still stuck, the details below help us pin it down fast.
13+
- type: input
14+
id: version
15+
attributes:
16+
label: Executor version
17+
description: Shown in the About menu (or Settings page). Please update to the latest first if you can.
18+
placeholder: "1.5.4"
19+
validations:
20+
required: true
21+
- type: dropdown
22+
id: os
23+
attributes:
24+
label: Operating system
25+
options:
26+
- macOS (Apple Silicon)
27+
- macOS (Intel)
28+
- Windows
29+
- Linux
30+
validations:
31+
required: true
32+
- type: dropdown
33+
id: when
34+
attributes:
35+
label: When does it crash?
36+
options:
37+
- On launch — the app never opens
38+
- Shortly after launch
39+
- While doing something specific (describe below)
40+
- Randomly during use
41+
validations:
42+
required: true
43+
- type: textarea
44+
id: what-happened
45+
attributes:
46+
label: What happened
47+
description: What were you doing when it crashed? Does it happen every time?
48+
validations:
49+
required: true
50+
- type: textarea
51+
id: logs
52+
attributes:
53+
label: Diagnostics / logs
54+
description: |
55+
If the app opens far enough to show a menu, use **Help → Export Diagnostics…**
56+
(or **Report a Problem…**) and attach the zip — it has everything we need and no secrets.
57+
58+
Otherwise, attach the log file directly:
59+
- macOS: `~/Library/Logs/Executor/main.log`
60+
- Windows: `%APPDATA%\Executor\logs\main.log`
61+
- Linux: `~/.config/Executor/logs/main.log`
62+
63+
(Please don't attach anything from `~/.executor` — that's where your data and secrets live.)
64+
placeholder: Drag the diagnostics zip or main.log here.
65+
- type: input
66+
id: run-id
67+
attributes:
68+
label: Run ID (optional)
69+
description: If a crash report was sent, the Run ID from the diagnostics manifest helps us find it.

apps/desktop/src/main/crash-screen.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
4545
}
4646
button.secondary { background: transparent; color: #fafafa; border-color: #3f3f46; }
4747
#status { margin-top: 1.25rem; min-height: 1.2em; font-size: 0.8rem; color: #a1a1aa; }
48+
.reset-row { margin-top: 1.75rem; font-size: 0.75rem; color: #71717a; }
49+
.reset-row a { color: #f87171; text-decoration: underline; cursor: pointer; }
4850
</style>
4951
</head>
5052
<body>
@@ -61,6 +63,11 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
6163
<button id="export" class="secondary">Export diagnostics</button>
6264
</div>
6365
<p id="status"></p>
66+
<p class="reset-row">
67+
Still won't start after restarting?
68+
<a id="reset" href="#">Reset data</a>
69+
clears a damaged database to get the app running again.
70+
</p>
6471
</main>
6572
<script>
6673
const status = document.getElementById("status");
@@ -92,6 +99,18 @@ export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<
9299
status.textContent = "Export failed \\u2014 see the log file.";
93100
}
94101
});
102+
document.getElementById("reset").addEventListener("click", async (event) => {
103+
event.preventDefault();
104+
try {
105+
// Native confirm runs in main; on accept, state is backed up and
106+
// the server restarts (window reloads on success). false = cancel.
107+
const didReset = await window.executor.resetState();
108+
if (!didReset) return;
109+
status.textContent = "";
110+
} catch {
111+
status.textContent = "Reset failed \\u2014 see the log file.";
112+
}
113+
});
95114
</script>
96115
</body>
97116
</html>`;

apps/desktop/src/main/index.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
reportAProblem,
3434
} from "./diagnostics";
3535
import { sidecarCrashHtml } from "./crash-screen";
36+
import { announceBackup, confirmResetState, resetExecutorState } from "./reset-state";
3637
import {
3738
getServerProfiles,
3839
getServerSettings,
@@ -333,6 +334,20 @@ const registerIpcHandlers = () => {
333334
// fixed upstream. Reuses the menu flow — staged updates prompt to install,
334335
// "no updates" / failures surface in their own dialogs.
335336
ipcMain.handle("executor:updates:check", () => runUpdateCheck({ alertOnFail: true }));
337+
// Crash-screen last resort for damaged state: confirm, move the data dir
338+
// aside (never delete), then restart the sidecar against the fresh dir.
339+
// Returns false when the user cancelled.
340+
ipcMain.handle("executor:state:reset", async (): Promise<boolean> => {
341+
if (!(await confirmResetState())) return false;
342+
if (connection) {
343+
await stopSidecar(connection.child);
344+
connection = null;
345+
}
346+
const { backupDir } = resetExecutorState();
347+
await restartSidecarAndReload();
348+
await announceBackup(backupDir);
349+
return true;
350+
});
336351
ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {
337352
if (typeof rawUrl !== "string") return;
338353
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input
@@ -475,13 +490,24 @@ const handleFatalSidecarFailure = async (error: unknown) => {
475490
}
476491
// 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
477492
const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
478-
await dialog.showMessageBox({
493+
const { response } = await dialog.showMessageBox({
479494
type: "error",
480495
title: "Executor failed to start",
481496
message: "The local Executor server crashed during startup.",
482497
detail: `${detail.slice(0, 1800)}\n\nFull log: ${log.transports.file.getFile().path}`,
483-
buttons: ["Quit"],
498+
buttons: ["Quit", "Reset data and retry…"],
499+
defaultId: 0,
500+
cancelId: 0,
484501
});
502+
// Damaged executor state (failed migration, corrupt SQLite) makes startup
503+
// fail forever — updating can't fix it. Offer the move-aside reset and one
504+
// immediate retry. Returns true when boot should be attempted again.
505+
if (response === 1 && (await confirmResetState())) {
506+
const { backupDir } = resetExecutorState();
507+
await announceBackup(backupDir);
508+
return true;
509+
}
510+
return false;
485511
};
486512

487513
const installApplicationMenu = () => {
@@ -545,15 +571,22 @@ const boot = async () => {
545571
void runUpdateCheck({ alertOnFail: false });
546572
});
547573
connection = await startWithCurrentSettings();
548-
if (!connection) {
574+
if (!connection && lastSidecarStartError != null) {
549575
// Port conflicts already showed their dialog inside
550576
// startWithCurrentSettings; every other failure surfaces here so the app
551-
// never silently bounces-and-vanishes. Pointing a window at the
552-
// (unreachable) baseUrl would just show ECONNREFUSED — a placeholder URL
553-
// would be worse. For now: explain, offer the updater a chance, quit.
554-
if (lastSidecarStartError != null) {
555-
await handleFatalSidecarFailure(lastSidecarStartError);
577+
// never silently bounces-and-vanishes. The dialog offers a data reset
578+
// (move-aside, for damaged state) — when taken, retry the boot once
579+
// against the fresh dir.
580+
const retryAfterReset = await handleFatalSidecarFailure(lastSidecarStartError);
581+
if (retryAfterReset) {
582+
lastSidecarStartError = null;
583+
connection = await startWithCurrentSettings();
584+
if (!connection && lastSidecarStartError != null) {
585+
await handleFatalSidecarFailure(lastSidecarStartError);
586+
}
556587
}
588+
}
589+
if (!connection) {
557590
app.quit();
558591
return;
559592
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Last-resort recovery for a data dir the sidecar can no longer open
3+
* (failed migration, corrupted SQLite, …): move the executor state aside
4+
* and start fresh.
5+
*
6+
* Strictly backup-then-move — nothing is ever deleted. data.db holds the
7+
* user's connections and secrets, so the old state lands in a timestamped
8+
* folder under ~/.executor/backups/ where it can be restored by copying
9+
* the files back.
10+
*
11+
* Scope: only the sidecar-owned state (data.db + SQLite sidecar files and
12+
* server-control/). The plugin manifest (executor.jsonc) is user-authored
13+
* config, not state — a reset shouldn't discard hand-written setup — and
14+
* desktop settings (port/auth) live in Electron's own store, unaffected.
15+
*/
16+
17+
import { existsSync, mkdirSync, renameSync } from "node:fs";
18+
import { homedir } from "node:os";
19+
import { join } from "node:path";
20+
import { dialog, shell } from "electron";
21+
import log from "electron-log/main.js";
22+
23+
const STATE_ENTRIES = ["data.db", "data.db-wal", "data.db-shm", "server-control"];
24+
25+
const backupStamp = () =>
26+
new Date()
27+
.toISOString()
28+
.replace(/[-:]/g, "")
29+
.replace(/\.\d+Z$/, "Z");
30+
31+
export interface ResetStateResult {
32+
readonly backupDir: string;
33+
readonly moved: ReadonlyArray<string>;
34+
}
35+
36+
/**
37+
* Move the executor state into ~/.executor/backups/<stamp>/. The caller is
38+
* responsible for stopping the sidecar first and restarting it after.
39+
*/
40+
export const resetExecutorState = (): ResetStateResult => {
41+
const dataDir = join(homedir(), ".executor");
42+
const backupDir = join(dataDir, "backups", backupStamp());
43+
mkdirSync(backupDir, { recursive: true });
44+
const moved: string[] = [];
45+
for (const entry of STATE_ENTRIES) {
46+
const from = join(dataDir, entry);
47+
if (!existsSync(from)) continue;
48+
renameSync(from, join(backupDir, entry));
49+
moved.push(entry);
50+
}
51+
log.info("[reset-state] moved executor state to backup", { backupDir, moved });
52+
return { backupDir, moved };
53+
};
54+
55+
/**
56+
* Confirmation dialog shared by every surface that offers a reset. Returns
57+
* true when the user explicitly chose to reset.
58+
*
59+
* EXECUTOR_TEST_AUTO_CONFIRM_RESET=1 skips the dialog — native dialogs are
60+
* unreachable from Playwright, and the e2e crash-recovery scenario needs to
61+
* drive the full reset path.
62+
*/
63+
export const confirmResetState = async (): Promise<boolean> => {
64+
if (process.env.EXECUTOR_TEST_AUTO_CONFIRM_RESET === "1") return true;
65+
const { response } = await dialog.showMessageBox({
66+
type: "warning",
67+
title: "Reset Executor data?",
68+
message: "Start over with a fresh data directory?",
69+
detail:
70+
"Your current data — integrations, connections, and history — will be moved to " +
71+
"~/.executor/backups (not deleted), and Executor will restart with a clean slate. " +
72+
"Use this when the app can't start because its data is damaged.",
73+
buttons: ["Reset and back up", "Cancel"],
74+
defaultId: 1,
75+
cancelId: 1,
76+
});
77+
return response === 0;
78+
};
79+
80+
/**
81+
* Make the "your data is backed up" promise concrete after a reset: name the
82+
* exact folder and offer to open it. Without this the user is told their data
83+
* is safe somewhere but has no way to act on it.
84+
*/
85+
export const announceBackup = async (backupDir: string): Promise<void> => {
86+
// Same test seam as confirmResetState: a modal with no one to dismiss it
87+
// would hang the e2e reset path.
88+
if (process.env.EXECUTOR_TEST_AUTO_CONFIRM_RESET === "1") {
89+
log.info("[reset-state] backup announced (test mode, dialog skipped)", { backupDir });
90+
return;
91+
}
92+
const { response } = await dialog.showMessageBox({
93+
type: "info",
94+
title: "Executor data reset",
95+
message: "Your previous data has been backed up.",
96+
detail:
97+
`If you need anything from before the reset, it's all here:\n\n${backupDir}\n\n` +
98+
"You can ignore this folder otherwise — Executor is now running on a fresh data directory.",
99+
buttons: ["Show in folder", "OK"],
100+
defaultId: 1,
101+
cancelId: 1,
102+
});
103+
if (response === 0) shell.showItemInFolder(backupDir);
104+
};

apps/desktop/src/preload/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ const api = {
5555
checkForUpdates(): Promise<void> {
5656
return ipcRenderer.invoke("executor:updates:check");
5757
},
58+
/**
59+
* Last-resort recovery for damaged executor state: after a native confirm,
60+
* back up the data dir (move-aside, never delete), then restart the
61+
* sidecar fresh. Resolves false when the user cancels the confirm.
62+
*/
63+
resetState(): Promise<boolean> {
64+
return ipcRenderer.invoke("executor:state:reset");
65+
},
5866
/**
5967
* Crash-reporting config for the renderer. Null unless this desktop build
6068
* shipped with a DSN baked in — the shared web UI only initializes its

0 commit comments

Comments
 (0)