/** * 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, shell } 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; }; /** * Make the "your data is backed up" promise concrete after a reset: name the * exact folder and offer to open it. Without this the user is told their data * is safe somewhere but has no way to act on it. */ export const announceBackup = async (backupDir: string): Promise => { // Same test seam as confirmResetState: a modal with no one to dismiss it // would hang the e2e reset path. if (process.env.EXECUTOR_TEST_AUTO_CONFIRM_RESET === "1") { log.info("[reset-state] backup announced (test mode, dialog skipped)", { backupDir }); return; } const { response } = await dialog.showMessageBox({ type: "info", title: "Executor data reset", message: "Your previous data has been backed up.", detail: `If you need anything from before the reset, it's all here:\n\n${backupDir}\n\n` + "You can ignore this folder otherwise — Executor is now running on a fresh data directory.", buttons: ["Show in folder", "OK"], defaultId: 1, cancelId: 1, }); if (response === 0) shell.showItemInFolder(backupDir); };