diff --git a/apps/desktop/e2e/access-account-recovery.spec.ts b/apps/desktop/e2e/access-account-recovery.spec.ts index 756e738..8588acb 100644 --- a/apps/desktop/e2e/access-account-recovery.spec.ts +++ b/apps/desktop/e2e/access-account-recovery.spec.ts @@ -34,6 +34,9 @@ async function mockAccountRecovery(page: Page, state: LockedState, options: { if (options.failSnapshot) throw "snapshot_unavailable"; return snapshot; } + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_logout") { await new Promise((resolve) => Object.assign(window, { __accessFinishLogout: resolve })); if (options.failLogout) throw "logout_unconfirmed"; diff --git a/apps/desktop/e2e/access-update-recovery.spec.ts b/apps/desktop/e2e/access-update-recovery.spec.ts index 86356f5..bc6e307 100644 --- a/apps/desktop/e2e/access-update-recovery.spec.ts +++ b/apps/desktop/e2e/access-update-recovery.spec.ts @@ -41,6 +41,9 @@ async function mockAccessUpdates(page: Page, state: "signed_out" | "unknown" | " } if (command === "plugin:event|unlisten") return; if (command === "desktop_snapshot") return snapshots[state]; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "refresh_desktop_snapshot") return authOnlyUnsupported ? snapshots.unknown : snapshots[state]; if (command === "desktop_login" && authOnlyUnsupported) throw "runtime_auth_only_unsupported"; if (command === "desktop_logout" && authOnlyUnsupported) return snapshots.signed_out; diff --git a/apps/desktop/e2e/account-effect-verification.spec.ts b/apps/desktop/e2e/account-effect-verification.spec.ts index 69e4004..151a580 100644 --- a/apps/desktop/e2e/account-effect-verification.spec.ts +++ b/apps/desktop/e2e/account-effect-verification.spec.ts @@ -21,6 +21,9 @@ async function mockAccountEffects(page: Page, options: { invoke: async (command: string) => { calls.push(command); if (command === "desktop_snapshot") return states[current]; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_login") { // OAuth completed, but its following snapshot was not confirmed. current = "active"; diff --git a/apps/desktop/e2e/install-partial-diagnostic.spec.ts b/apps/desktop/e2e/install-partial-diagnostic.spec.ts index e880b68..e674750 100644 --- a/apps/desktop/e2e/install-partial-diagnostic.spec.ts +++ b/apps/desktop/e2e/install-partial-diagnostic.spec.ts @@ -5,20 +5,26 @@ test("a typed partial receipt shows sanitized steps without enabling another ins const snapshot = createDemoSnapshot("active"); snapshot.source = "runtime"; await page.addInitScript(({ snapshot }) => { - let applications = 0; - Object.assign(window, { __installDiagnosticApplications: () => applications, __TAURI_INTERNALS__: { + const persistedError = { + schema: "simplicio.desktop-install-error/v1", code: "integration_install_exit_code:1", + diagnostic: { schema: "simplicio.desktop-install-diagnostic/v1", status: "partial", failedSteps: ["hermes"], unknownFailedSteps: 0 }, + }; + Object.assign(window, { __installDiagnosticApplications: () => Number(localStorage.getItem("install-applications") || "0"), __TAURI_INTERNALS__: { invoke: async (command: string) => { if (command === "desktop_snapshot" || command === "refresh_desktop_snapshot") return snapshot; + if (command === "desktop_install_diagnostic") return localStorage.getItem("install-pending") === "1" + ? { schema: "simplicio.desktop-install-attempt/v1", status: "reconciliation_required", error: persistedError } + : { schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null }; if (command === "desktop_plan_integrations") return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: "sha256:" + "a".repeat(64), changes: [{ label: "hermes", exists: true, changed: true }], }; if (command === "desktop_repair_providers") { - applications += 1; + localStorage.setItem("install-applications", String(Number(localStorage.getItem("install-applications") || "0") + 1)); + localStorage.setItem("install-pending", "1"); throw { - schema: "simplicio.desktop-install-error/v1", code: "integration_install_exit_code:1", - diagnostic: { schema: "simplicio.desktop-install-diagnostic/v1", status: "partial", failedSteps: ["hermes"], unknownFailedSteps: 0 }, + ...persistedError, detail: "DO_NOT_LEAK /private/test-user", }; } @@ -46,4 +52,11 @@ test("a typed partial receipt shows sanitized steps without enabling another ins await integration.getByRole("button", { name: "Atualizar diagnóstico", exact: true }).click(); await expect(page.getByRole("heading", { name: "Runtime e diagnóstico", exact: true })).toBeVisible(); expect(await page.evaluate(() => (window as Window & { __installDiagnosticApplications: () => number }).__installDiagnosticApplications())).toBe(1); + + await page.goto("/?view=setup"); + await expect(page.getByRole("alert")).toContainText("Etapas com falha: Hermes."); + await expect(page.getByRole("alert")).toContainText("código 1."); + await expect(page.getByRole("button", { name: "Configurar Simplicio", exact: true })).toBeDisabled(); + await expect(page.locator("body")).not.toContainText("DO_NOT_LEAK"); + expect(await page.evaluate(() => (window as Window & { __installDiagnosticApplications: () => number }).__installDiagnosticApplications())).toBe(1); }); diff --git a/apps/desktop/e2e/integration-preflight-recovery.spec.ts b/apps/desktop/e2e/integration-preflight-recovery.spec.ts index cdb5653..c8d3b45 100644 --- a/apps/desktop/e2e/integration-preflight-recovery.spec.ts +++ b/apps/desktop/e2e/integration-preflight-recovery.spec.ts @@ -39,6 +39,9 @@ async function preparePreflightFailure(page: Page) { invoke: async (command: string, args: Record = {}) => { calls.push(command); if (command === "desktop_snapshot" || command === "refresh_desktop_snapshot") return snapshot; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_plan_integrations") { reviews += 1; return { diff --git a/apps/desktop/e2e/reference-setup-recovery.spec.ts b/apps/desktop/e2e/reference-setup-recovery.spec.ts index bfa2724..fda9beb 100644 --- a/apps/desktop/e2e/reference-setup-recovery.spec.ts +++ b/apps/desktop/e2e/reference-setup-recovery.spec.ts @@ -12,6 +12,9 @@ async function prepareRecovery(page: Page, failure: string) { invoke: async (command: string) => { calls.push(command); if (command === "desktop_snapshot" || command === "refresh_desktop_snapshot") return snapshot; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_plan_integrations") return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: "sha256:" + "a".repeat(64), diff --git a/apps/desktop/e2e/reference-setup.spec.ts b/apps/desktop/e2e/reference-setup.spec.ts index 4be6eba..d6a6e51 100644 --- a/apps/desktop/e2e/reference-setup.spec.ts +++ b/apps/desktop/e2e/reference-setup.spec.ts @@ -14,6 +14,9 @@ test("guided setup keeps progress and consent-controlled actions visible in a sh Object.assign(window, { __referenceApplyCount: 0, __TAURI_INTERNALS__: { invoke: async (command: string) => { if (command === "desktop_snapshot" || command === "refresh_desktop_snapshot") return snapshot; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_plan_integrations") return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: "sha256:" + (applied ? "b" : "a").repeat(64), diff --git a/apps/desktop/e2e/runtime-integration.spec.ts b/apps/desktop/e2e/runtime-integration.spec.ts index 1870620..d69f678 100644 --- a/apps/desktop/e2e/runtime-integration.spec.ts +++ b/apps/desktop/e2e/runtime-integration.spec.ts @@ -23,6 +23,9 @@ async function mockNativeBridge(page: Page, options: { signedOut?: boolean; fail if (command === "desktop_logout") signedOut = true; if (command === "refresh_desktop_snapshot" && installed && options.failVerification) throw "test_final_snapshot_failed"; if (["desktop_snapshot", "refresh_desktop_snapshot", "desktop_logout"].includes(command)) return { ...snapshot, access: { ...snapshot.access, state: signedOut ? "signed_out" : accessState } }; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_plan_integrations") return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: `sha256:${(installed ? "b" : "a").repeat(64)}`, changes: [{ label: "codex", changed: !installed, exists: true }] }; if (command === "desktop_repair_providers") { if (options.pauseSetup) await new Promise((resolve) => Object.assign(window, { __desktopCompleteSetup: resolve })); diff --git a/apps/desktop/e2e/setup-plan-labels.spec.ts b/apps/desktop/e2e/setup-plan-labels.spec.ts index 1bbd7ff..f083c1a 100644 --- a/apps/desktop/e2e/setup-plan-labels.spec.ts +++ b/apps/desktop/e2e/setup-plan-labels.spec.ts @@ -8,6 +8,9 @@ test("guided setup distinguishes absent configuration from an unchanged installe Object.assign(window, { __TAURI_INTERNALS__: { invoke: async (command: string) => { if (command === "desktop_snapshot" || command === "refresh_desktop_snapshot") return snapshot; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_plan_integrations") return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: "sha256:" + "a".repeat(64), diff --git a/apps/desktop/e2e/setup-post-apply-verification.spec.ts b/apps/desktop/e2e/setup-post-apply-verification.spec.ts index 86cefd7..0061131 100644 --- a/apps/desktop/e2e/setup-post-apply-verification.spec.ts +++ b/apps/desktop/e2e/setup-post-apply-verification.spec.ts @@ -17,6 +17,9 @@ async function mockSetupVerification(page: Page, options: { state?: Verification invoke: async (command: string, args: Record = {}) => { calls.push(command); if (command === "desktop_snapshot" || command === "refresh_desktop_snapshot") return snapshot; + if (command === "desktop_install_diagnostic") return { + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + }; if (command === "desktop_plan_integrations") { if (!applied) return { schema: "simplicio.desktop-integration-plan/v1", source: "runtime", planDigest: `sha256:${"a".repeat(64)}`, diff --git a/apps/desktop/src-tauri/src/install_result.rs b/apps/desktop/src-tauri/src/install_result.rs index d66afa3..f664c4a 100644 --- a/apps/desktop/src-tauri/src/install_result.rs +++ b/apps/desktop/src-tauri/src/install_result.rs @@ -1,9 +1,14 @@ use serde_json::Value; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; const MAX_INSTALL_OUTPUT_BYTES: usize = 64 * 1024; const MAX_INSTALL_ACTIONS: usize = 128; +const INSTALL_ATTEMPT_SCHEMA: &str = "simplicio.desktop-install-attempt/v1"; + /// A closed projection: no raw action name, path, detail, stdout or stderr. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InstallDiagnostic { @@ -15,6 +20,164 @@ pub struct InstallDiagnostic { pub type InstallError = Value; +fn journal_sibling(path: &Path, suffix: &str) -> PathBuf { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + path.with_extension(format!("{extension}.{suffix}")) +} + +fn write_journal(path: &Path, state: &str, error: Option) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing journal parent"))?; + fs::create_dir_all(parent)?; + let temporary = journal_sibling(path, "tmp"); + let backup = journal_sibling(path, "bak"); + let _ = fs::remove_file(&temporary); + let mut file = File::create(&temporary)?; + let record = serde_json::json!({ + "schema": INSTALL_ATTEMPT_SCHEMA, + "state": state, + "error": error, + }); + serde_json::to_writer(&mut file, &record).map_err(io::Error::other)?; + file.write_all(b"\n")?; + file.sync_all()?; + + if path.exists() { + let _ = fs::remove_file(&backup); + fs::rename(path, &backup)?; + } + if let Err(error) = fs::rename(&temporary, path) { + if !path.exists() && backup.exists() { + let _ = fs::rename(&backup, path); + } + return Err(error); + } + if let Ok(directory) = File::open(parent) { + let _ = directory.sync_all(); + } + Ok(()) +} + +fn valid_exit_code(code: &str) -> bool { + let Some(raw) = code.strip_prefix("integration_install_exit_code:") else { + return false; + }; + raw.len() <= 11 && raw.parse::().is_ok_and(|value| value != 0) +} + +fn valid_public_code(code: &str) -> bool { + matches!( + code, + "integration_install_busy" + | "integration_preflight_unavailable" + | "integration_plan_changed_review_again" + | "integration_install_output_unavailable" + | "integration_install_not_started" + | "integration_install_timeout" + | "integration_install_stderr_too_large" + | "integration_install_cleanup_unconfirmed" + | "integration_install_reconciliation_required" + | "integration_install_no_exit_code" + | "integration_install_invalid_json" + | "integration_install_response_too_large" + | "integration_install_receipt_unconfirmed" + | "integration_install_applied_snapshot_unavailable" + ) || valid_exit_code(code) +} + +fn valid_step_label(step: &str) -> bool { + matches!( + step, + "binary-copy" + | "path-registration" + | "install-manifest" + | "codex" + | "codex-hooks" + | "mcp-route-hook" + | "hermes" + | "claude-code" + | "claude-code-hooks" + | "claude-desktop" + | "cursor" + | "windsurf" + | "windsurf-next" + | "kiro" + | "gemini" + | "trae" + | "antigravity" + | "jetbrains-junie" + | "vscode-cline" + | "vscode" + | "zed" + | "opencode" + | "grok-mcp-route" + ) +} + +fn sanitized_public_error(value: &Value) -> Option { + let object = value.as_object()?; + if object.len() < 2 + || object.len() > 3 + || value.get("schema")?.as_str()? != "simplicio.desktop-install-error/v1" + { + return None; + } + let code = value.get("code")?.as_str()?; + if code.len() > 100 || !valid_public_code(code) { + return None; + } + let Some(diagnostic) = value.get("diagnostic") else { + return (object.len() == 2).then(|| { + serde_json::json!({ + "schema": "simplicio.desktop-install-error/v1", + "code": code, + }) + }); + }; + if !valid_exit_code(code) || object.len() != 3 { + return None; + } + let diagnostic_object = diagnostic.as_object()?; + if diagnostic_object.len() != 4 + || diagnostic.get("schema")?.as_str()? != "simplicio.desktop-install-diagnostic/v1" + || diagnostic.get("status")?.as_str()? != "partial" + { + return None; + } + let failed_steps = diagnostic.get("failedSteps")?.as_array()?; + let unknown = diagnostic.get("unknownFailedSteps")?.as_u64()?; + if failed_steps.len() > MAX_INSTALL_ACTIONS + || unknown > MAX_INSTALL_ACTIONS as u64 + || failed_steps.len() as u64 + unknown == 0 + || failed_steps.len() as u64 + unknown > MAX_INSTALL_ACTIONS as u64 + { + return None; + } + let mut seen = std::collections::HashSet::new(); + let mut steps = Vec::with_capacity(failed_steps.len()); + for value in failed_steps { + let step = value.as_str()?; + if !valid_step_label(step) || !seen.insert(step) { + return None; + } + steps.push(step); + } + Some(serde_json::json!({ + "schema": "simplicio.desktop-install-error/v1", + "code": code, + "diagnostic": { + "schema": "simplicio.desktop-install-diagnostic/v1", + "status": "partial", + "failedSteps": steps, + "unknownFailedSteps": unknown, + } + })) +} + fn known_step(name: &str) -> Option<&'static str> { match name { "binary-copy" => Some("binary-copy"), @@ -157,16 +320,95 @@ impl InstallFailure { } } -/// This state survives closing/reopening the dialog, not application restart. -/// It is not a substitute for a durable Runtime effect-reconciliation receipt. +/// Process-local mirror of the durable Desktop installation journal. pub struct InstallAttempt { reconciliation_required: bool, + last_error: Option, } impl InstallAttempt { pub const fn new() -> Self { Self { reconciliation_required: false, + last_error: None, + } + } + + fn blocked(error: Option) -> Self { + Self { + reconciliation_required: true, + last_error: error, + } + } + + /// Restore only the closed, sanitized projection. Missing evidence is clear; + /// interrupted, malformed, or unreadable evidence fails closed. + pub fn load(path: &Path) -> Self { + let backup = journal_sibling(path, "bak"); + let temporary = journal_sibling(path, "tmp"); + let selected = if path.exists() { + Some(path.to_path_buf()) + } else if backup.exists() { + Some(backup) + } else if temporary.exists() { + Some(temporary) + } else { + None + }; + let Some(selected) = selected else { + return Self::new(); + }; + let Ok(bytes) = fs::read(selected) else { + return Self::blocked(None); + }; + if bytes.len() > 4096 { + return Self::blocked(None); + } + let Ok(record) = serde_json::from_slice::(&bytes) else { + return Self::blocked(None); + }; + let Some(object) = record.as_object() else { + return Self::blocked(None); + }; + if object.len() != 3 + || record.get("schema").and_then(Value::as_str) != Some(INSTALL_ATTEMPT_SCHEMA) + { + return Self::blocked(None); + } + match record.get("state").and_then(Value::as_str) { + Some("settled") if record.get("error").is_some_and(Value::is_null) => Self::new(), + Some("in_progress") if record.get("error").is_some_and(Value::is_null) => { + Self::blocked(None) + } + Some("reconciliation_required") => record + .get("error") + .and_then(sanitized_public_error) + .map(|error| Self::blocked(Some(error))) + .unwrap_or_else(|| Self::blocked(None)), + _ => Self::blocked(None), + } + } + + pub fn pending_error(&self) -> Option { + self.reconciliation_required.then(|| { + self.last_error + .clone() + .unwrap_or_else(|| InstallFailure::ReconciliationRequired.public_error()) + }) + } + + pub fn diagnostic(&self) -> Value { + match self.pending_error() { + Some(error) => serde_json::json!({ + "schema": INSTALL_ATTEMPT_SCHEMA, + "status": "reconciliation_required", + "error": error, + }), + None => serde_json::json!({ + "schema": INSTALL_ATTEMPT_SCHEMA, + "status": "clear", + "error": null, + }), } } @@ -181,6 +423,15 @@ impl InstallAttempt { pub fn begin(&mut self) -> Result<(), InstallFailure> { self.check_ready()?; self.reconciliation_required = true; + self.last_error = None; + Ok(()) + } + + pub fn begin_persisted(&mut self, path: &Path) -> Result<(), InstallFailure> { + self.check_ready()?; + write_journal(path, "in_progress", None).map_err(|_| InstallFailure::NotStarted)?; + self.reconciliation_required = true; + self.last_error = None; Ok(()) } @@ -189,8 +440,30 @@ impl InstallAttempt { // settles this attempt. Exit failure can still mean partially applied. if matches!(result, Ok(()) | Err(InstallFailure::NotStarted)) { self.reconciliation_required = false; + self.last_error = None; + } else if let Err(failure) = result { + self.last_error = Some(failure.public_error()); } } + + pub fn finish_persisted( + &mut self, + path: &Path, + result: &Result<(), InstallFailure>, + ) -> Result<(), InstallFailure> { + self.finish(result); + let (state, error) = if self.reconciliation_required { + ("reconciliation_required", self.pending_error()) + } else { + ("settled", None) + }; + if write_journal(path, state, error).is_err() { + self.reconciliation_required = true; + self.last_error = Some(InstallFailure::ReconciliationRequired.public_error()); + return Err(InstallFailure::ReconciliationRequired); + } + Ok(()) + } } /// Interprets one completed invocation without executing commands or retrying. @@ -533,4 +806,81 @@ mod diagnostic_tests { Err(InstallFailure::ExitCode(1)) ); } + + fn journal_path(name: &str) -> std::path::PathBuf { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::env::temp_dir().join(format!( + "simplicio-desktop-install-{name}-{}-{id}.json", + std::process::id() + )) + } + + fn remove_journal(path: &std::path::Path) { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(path.with_extension("json.bak")); + let _ = std::fs::remove_file(path.with_extension("json.tmp")); + } + + #[test] + fn durable_journal_preserves_the_sanitized_code_one_diagnostic_after_restart() { + let path = journal_path("partial"); + remove_journal(&path); + let stdout = partial(json!([ + {"name":"assistant-config:hermes","status":"failed","detail":"DO_NOT_LEAK /private/test-user"}, + {"name":"private-step","status":"failed","token":"DO_NOT_LEAK"} + ])); + let failure = validate_install_output(Some(1), &stdout).unwrap_err(); + let mut attempt = InstallAttempt::new(); + attempt.begin_persisted(&path).unwrap(); + attempt.finish_persisted(&path, &Err(failure)).unwrap(); + + let restored = InstallAttempt::load(&path); + let diagnostic = restored + .pending_error() + .expect("durable pending diagnostic"); + assert_eq!(diagnostic["code"], "integration_install_exit_code:1"); + assert_eq!(diagnostic["diagnostic"]["failedSteps"], json!(["hermes"])); + assert_eq!(diagnostic["diagnostic"]["unknownFailedSteps"], 1); + let rendered = diagnostic.to_string(); + for secret in ["DO_NOT_LEAK", "/private/test-user", "private-step"] { + assert!(!rendered.contains(secret)); + } + remove_journal(&path); + } + + #[test] + fn an_interrupted_or_malformed_journal_fails_closed_after_restart() { + for contents in [ + r#"{"schema":"simplicio.desktop-install-attempt/v1","state":"in_progress"}"#, + r#"{"schema":"wrong","state":"failed","error":{"token":"DO_NOT_LEAK"}}"#, + ] { + let path = journal_path("uncertain"); + remove_journal(&path); + std::fs::write(&path, contents).unwrap(); + let restored = InstallAttempt::load(&path); + let diagnostic = restored + .pending_error() + .expect("uncertain attempts stay blocked"); + assert_eq!( + diagnostic["code"], + "integration_install_reconciliation_required" + ); + assert!(!diagnostic.to_string().contains("DO_NOT_LEAK")); + remove_journal(&path); + } + } + + #[test] + fn a_settled_attempt_is_clear_after_restart() { + let path = journal_path("settled"); + remove_journal(&path); + let mut attempt = InstallAttempt::new(); + attempt.begin_persisted(&path).unwrap(); + attempt.finish_persisted(&path, &Ok(())).unwrap(); + let restored = InstallAttempt::load(&path); + assert!(restored.pending_error().is_none()); + assert!(restored.check_ready().is_ok()); + remove_journal(&path); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index bdb8ff6..d462b77 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -77,6 +77,13 @@ const INSTALL_ARGS: &[&str] = &["install", "--global", "--yes", "--json"]; const SUBSCRIPTION_URL: &str = "https://simpleti.com.br/simplicio"; const RELEASES_URL: &str = "https://github.com/wesleysimplicio/simplicio/releases"; +fn install_attempt_path(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|directory| directory.join("install-attempt.json")) + .map_err(|_| install_result::InstallFailure::NotStarted.public_error()) +} + fn runtime_candidates_with( override_binary: Option, simplicio_home: Option, @@ -507,9 +514,11 @@ async fn desktop_logout() -> Result { #[tauri::command] async fn desktop_repair_providers( + app: tauri::AppHandle, plan_digest: String, ) -> Result { use install_result::InstallFailure; + let journal = install_attempt_path(&app)?; tauri::async_runtime::spawn_blocking(move || { let mut attempt = INSTALL_LOCK.try_lock().map_err(|error| match error { std::sync::TryLockError::WouldBlock => InstallFailure::Busy.public_error(), @@ -517,6 +526,10 @@ async fn desktop_repair_providers( InstallFailure::ReconciliationRequired.public_error() } })?; + *attempt = install_result::InstallAttempt::load(&journal); + if let Some(error) = attempt.pending_error() { + return Err(error); + } attempt .check_ready() .map_err(|failure| failure.public_error())?; @@ -526,9 +539,13 @@ async fn desktop_repair_providers( if plan["planDigest"].as_str() != Some(plan_digest.as_str()) { return Err(InstallFailure::PlanChanged.public_error()); } - attempt.begin().map_err(|failure| failure.public_error())?; + attempt + .begin_persisted(&journal) + .map_err(|failure| failure.public_error())?; let result = repair_provider_integrations(); - attempt.finish(&result); + attempt + .finish_persisted(&journal, &result) + .map_err(|failure| failure.public_error())?; result.map_err(|failure| failure.public_error())?; snapshot_from_runtime() .map_err(|_| InstallFailure::AppliedSnapshotUnavailable.public_error()) @@ -537,6 +554,20 @@ async fn desktop_repair_providers( .map_err(|_| InstallFailure::OutputUnavailable.public_error())? } +#[tauri::command] +async fn desktop_install_diagnostic( + app: tauri::AppHandle, +) -> Result { + let journal = install_attempt_path(&app)?; + tauri::async_runtime::spawn_blocking(move || { + Ok::<_, install_result::InstallError>( + install_result::InstallAttempt::load(&journal).diagnostic(), + ) + }) + .await + .map_err(|_| install_result::InstallFailure::OutputUnavailable.public_error())? +} + #[tauri::command] async fn desktop_open_subscription() -> Result<(), String> { // One fixed navigation, never retry an uncertain Runtime action with a second opener. @@ -579,6 +610,7 @@ pub fn run() { desktop_login, desktop_logout, desktop_repair_providers, + desktop_install_diagnostic, desktop_plan_integrations, desktop_usage_projects, desktop_context_report, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 3ff831e..51c5e37 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -5,6 +5,7 @@ import { snapshotWithDemoBots } from "./bot_center"; import { beginDesktopLogin, loadDesktopSnapshot, + loadDesktopInstallDiagnostic, logoutDesktop, openDesktopSubscription, refreshDesktopSnapshot, @@ -121,6 +122,23 @@ export function DesktopApp({ snapshot: initialSnapshot }: { snapshot?: DesktopSn }; }, [initialSnapshot]); + useEffect(() => { + if (view !== "setup") return; + let current = true; + loadDesktopInstallDiagnostic() + .then((diagnostic) => { + if (!current || diagnostic.status === "clear") return; + setActionError(installFailureMessage(diagnostic.error)); + setApplicationRecovery(installFailureRecovery(diagnostic.error)); + }) + .catch(() => { + if (!current) return; + setActionError("Não foi possível consultar o recibo persistente da instalação. Uma nova aplicação permanece bloqueada até o diagnóstico ser esclarecido."); + setApplicationRecovery("reconcile"); + }); + return () => { current = false; }; + }, [view]); + async function refresh() { if (actionLock.current) return; actionLock.current = true; diff --git a/apps/desktop/src/bridge.ts b/apps/desktop/src/bridge.ts index 49e9014..d677e6d 100644 --- a/apps/desktop/src/bridge.ts +++ b/apps/desktop/src/bridge.ts @@ -12,6 +12,7 @@ import { createReadonlyRequest } from "./readonly_request"; import { createContextReader } from "./context_report"; import { parseUsageProjects } from "./project_usage"; import { createConsolidatedReader, type ConsolidatedQuery, type ConsolidatedReport } from "./consolidated_tokens"; +import { parseInstallAttemptDiagnostic, type InstallAttemptDiagnostic } from "./install_failures"; const readSnapshot = createReadonlyRequest(30_000, "desktop_snapshot_timeout"); const readContext = createContextReader((repoPath) => invoke("desktop_context_report", { repoPath: repoPath || null })); @@ -107,6 +108,11 @@ export async function refreshDesktopSnapshot(): Promise { return readSnapshot(() => invoke("refresh_desktop_snapshot")); } +export async function loadDesktopInstallDiagnostic(): Promise { + if (!isTauri()) return { status: "clear", error: null }; + return parseInstallAttemptDiagnostic(await invoke("desktop_install_diagnostic")); +} + export async function planDesktopIntegrations(): Promise { if (!isTauri()) return { schema: "simplicio.desktop-integration-plan/v1", source: "preview", planDigest: `sha256:${"0".repeat(64)}`, changes: [{ label: "codex", exists: true, changed: false }, { label: "grok", exists: true, changed: true }] }; return parseIntegrationPlan(await withTimeout(invoke("desktop_plan_integrations"), 60_000, "integration_plan_timeout")); diff --git a/apps/desktop/src/install_failures.test.ts b/apps/desktop/src/install_failures.test.ts index 9cefcba..4ca28e7 100644 --- a/apps/desktop/src/install_failures.test.ts +++ b/apps/desktop/src/install_failures.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { installFailureMessage, installFailureRecovery } from "./install_failures"; +import { installFailureMessage, installFailureRecovery, parseInstallAttemptDiagnostic } from "./install_failures"; describe("safe installer failure messages", () => { it("shows only a bounded nonzero OS exit code and warns about partial effects", () => { @@ -167,3 +167,39 @@ describe("typed partial install diagnostics", () => { expect(installFailureRecovery(error)).toBe("reconcile"); }); }); + +describe("durable install-attempt diagnostics", () => { + it("restores a sanitized partial failure after an app restart", () => { + const error = { + schema: "simplicio.desktop-install-error/v1", + code: "integration_install_exit_code:1", + diagnostic: { + schema: "simplicio.desktop-install-diagnostic/v1", + status: "partial", + failedSteps: ["hermes"], + unknownFailedSteps: 1, + }, + }; + const result = parseInstallAttemptDiagnostic({ + schema: "simplicio.desktop-install-attempt/v1", + status: "reconciliation_required", + error, + }); + expect(result.status).toBe("reconciliation_required"); + expect(installFailureMessage(result.error)).toContain("Hermes"); + expect(installFailureMessage(result.error)).toContain("código 1"); + expect(installFailureRecovery(result.error)).toBe("reconcile"); + }); + + it("accepts only a closed clear state and rejects injected persisted details", () => { + expect(parseInstallAttemptDiagnostic({ + schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, + })).toEqual({ status: "clear", error: null }); + for (const value of [ + { schema: "wrong", status: "clear", error: null }, + { schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: { token: "DO_NOT_LEAK" } }, + { schema: "simplicio.desktop-install-attempt/v1", status: "reconciliation_required", error: { schema: "simplicio.desktop-install-error/v1", code: "DO_NOT_LEAK" } }, + { schema: "simplicio.desktop-install-attempt/v1", status: "clear", error: null, extra: "DO_NOT_LEAK" }, + ]) expect(() => parseInstallAttemptDiagnostic(value)).toThrow("integration_install_diagnostic_invalid"); + }); +}); diff --git a/apps/desktop/src/install_failures.ts b/apps/desktop/src/install_failures.ts index b06afbb..1d55e6c 100644 --- a/apps/desktop/src/install_failures.ts +++ b/apps/desktop/src/install_failures.ts @@ -84,6 +84,9 @@ const STEP_LABELS: Readonly> = { type PartialDiagnostic = { failedSteps: string[]; unknownFailedSteps: number }; type NativeInstallError = { code: string; diagnostic?: PartialDiagnostic }; +export type InstallAttemptDiagnostic = + | { status: "clear"; error: null } + | { status: "reconciliation_required"; error: unknown }; function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -117,6 +120,17 @@ function typedError(error: unknown): NativeInstallError | undefined { return { code, diagnostic: { failedSteps: steps, unknownFailedSteps: value.unknownFailedSteps } }; } +/** Parse the native durable journal without accepting arbitrary stored content. */ +export function parseInstallAttemptDiagnostic(value: unknown): InstallAttemptDiagnostic { + if (!record(value) || Object.keys(value).length !== 3 || + value.schema !== "simplicio.desktop-install-attempt/v1") throw new Error("integration_install_diagnostic_invalid"); + if (value.status === "clear" && value.error === null) return { status: "clear", error: null }; + if (value.status === "reconciliation_required" && typedError(value.error)) { + return { status: "reconciliation_required", error: value.error }; + } + throw new Error("integration_install_diagnostic_invalid"); +} + /** Fixed labels only: diagnostic payload details never reach the UI. */ export function installFailureMessage(error: unknown): string { const message = baseFailureMessage(error);